Initial commit: Obsidian Web Interface for Claude Code

- Full IDE with terminal integration using xterm.js
- Session management with local and web sessions
- HTML preview functionality
- Multi-terminal support with session picker

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
uroma
2026-01-19 16:29:44 +00:00
Unverified
commit 0dd2083556
44 changed files with 18955 additions and 0 deletions

View File

@@ -0,0 +1,475 @@
/**
* Enhanced Chat Styles - Similar to chat.z.ai
* Smooth animations, better input, modern design
*/
/* ============================================
Chat Input Enhancements
============================================ */
.chat-input-wrapper {
transition: all 0.3s ease;
border: 2px solid #333;
}
.chat-input-wrapper.input-focused {
border-color: #4a9eff;
box-shadow: 0 0 0 3px rgba(74, 158, 255, 0.1);
}
#chat-input {
transition: height 0.2s ease;
font-size: 15px;
line-height: 1.6;
}
#chat-input:focus {
outline: none;
}
/* ============================================
Chat History Sidebar
============================================ */
.chat-history-list {
padding: 12px;
}
.chat-history-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: 8px;
background: #252525;
border: 1px solid transparent;
}
.chat-history-item:hover {
background: #2a2a2a;
border-color: #444;
transform: translateX(2px);
}
.chat-history-item.active {
background: #1a3a5a;
border-color: #4a9eff;
}
.chat-history-item.historical {
opacity: 0.8;
}
.chat-history-icon {
font-size: 20px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: #1a1a1a;
border-radius: 8px;
}
.chat-history-content {
flex: 1;
min-width: 0;
}
.chat-history-title {
font-size: 14px;
font-weight: 500;
color: #e0e0e0;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-history-meta {
display: flex;
gap: 8px;
font-size: 12px;
color: #888;
}
.chat-history-status {
padding: 2px 6px;
border-radius: 4px;
background: #1a1a1a;
}
.chat-history-status.active {
color: #51cf66;
background: rgba(81, 207, 102, 0.1);
}
.chat-history-status.historical {
color: #ffa94d;
background: rgba(255, 169, 77, 0.1);
}
.resume-badge {
font-size: 11px;
padding: 4px 8px;
background: #4a9eff;
color: white;
border-radius: 4px;
font-weight: 500;
}
.chat-history-empty {
text-align: center;
padding: 40px 20px;
color: #888;
font-size: 14px;
}
/* ============================================
Enhanced Message Animations
============================================ */
.chat-message {
animation: messageSlideIn 0.3s ease;
}
@keyframes messageSlideIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message-appear {
animation: messageAppear 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes messageAppear {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.message-avatar {
animation: avatarPop 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
@keyframes avatarPop {
0% {
transform: scale(0);
}
50% {
transform: scale(1.2);
}
100% {
transform: scale(1);
}
}
/* ============================================
Code Operation Styling
============================================ */
.code-operation {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
margin: 12px 0;
overflow: hidden;
}
.operation-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: #252525;
border-bottom: 1px solid #333;
}
.operation-icon {
font-size: 16px;
}
.operation-label {
font-size: 13px;
font-weight: 500;
color: #e0e0e0;
}
.operation-code {
margin: 0;
padding: 14px;
background: #0d0d0d;
overflow-x: auto;
}
.operation-code code {
font-family: 'Fira Code', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
color: #e0e0e0;
}
/* ============================================
Quick Actions
============================================ */
.quick-actions {
padding: 20px;
background: #1a1a1a;
border-radius: 12px;
margin: 20px 0;
animation: quickActionsSlide 0.4s ease;
}
@keyframes quickActionsSlide {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.quick-actions-title {
font-size: 14px;
font-weight: 600;
color: #888;
margin-bottom: 16px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.quick-actions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
}
.quick-action-btn {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: #252525;
border: 1px solid #333;
border-radius: 8px;
color: #e0e0e0;
cursor: pointer;
transition: all 0.2s ease;
font-size: 14px;
}
.quick-action-btn:hover {
background: #2a2a2a;
border-color: #4a9eff;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(74, 158, 255, 0.2);
}
.quick-action-btn:active {
transform: translateY(0);
}
.action-icon {
font-size: 24px;
}
.action-label {
flex: 1;
text-align: left;
font-weight: 500;
}
/* ============================================
Tag Placeholders
============================================ */
.tag-placeholder {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: #1a3a5a;
border: 1px solid #4a9eff;
border-radius: 4px;
font-size: 12px;
color: #4a9eff;
margin: 4px;
}
/* ============================================
Enhanced Chat Layout
============================================ */
.chat-layout {
height: calc(100vh - 60px);
overflow: hidden;
}
.chat-main {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 20px;
scroll-behavior: smooth;
}
.chat-messages::-webkit-scrollbar {
width: 8px;
}
.chat-messages::-webkit-scrollbar-track {
background: #1a1a1a;
}
.chat-messages::-webkit-scrollbar-thumb {
background: #333;
border-radius: 4px;
}
.chat-messages::-webkit-scrollbar-thumb:hover {
background: #444;
}
.chat-input-container {
border-top: 1px solid #333;
background: #1a1a1a;
padding: 16px 20px;
}
/* ============================================
Message Header Styling
============================================ */
.message-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.message-label {
font-size: 13px;
font-weight: 600;
color: #e0e0e0;
}
.message-time {
font-size: 11px;
color: #888;
}
/* ============================================
Responsive Design
============================================ */
@media (max-width: 768px) {
.quick-actions-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px;
}
.chat-history-item {
padding: 10px;
}
.quick-action-btn {
padding: 12px;
}
.action-icon {
font-size: 20px;
}
.action-label {
font-size: 13px;
}
}
/* ============================================
Loading States
============================================ */
.chat-history-loading {
display: flex;
justify-content: center;
padding: 20px;
}
.chat-history-loading .spinner {
width: 30px;
height: 30px;
border: 3px solid #333;
border-top-color: #4a9eff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ============================================
Typing Indicator
============================================ */
.typing-indicator {
display: flex;
gap: 4px;
padding: 12px 16px;
background: #1a1a1a;
border-radius: 12px;
width: fit-content;
margin: 8px 0;
}
.typing-indicator .dot {
width: 8px;
height: 8px;
background: #888;
border-radius: 50%;
animation: typingBounce 1.4s ease-in-out infinite;
}
.typing-indicator .dot:nth-child(1) {
animation-delay: 0s;
}
.typing-indicator .dot:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator .dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typingBounce {
0%, 60%, 100% {
transform: translateY(0);
}
30% {
transform: translateY(-8px);
}
}

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

@@ -0,0 +1,499 @@
// ============================================
// Chat Interface Functions
// ============================================
let chatSessionId = null;
let chatMessages = [];
let attachedSessionId = null;
// Reset all chat state
function resetChatState() {
console.log('Resetting chat state...');
chatSessionId = null;
chatMessages = [];
attachedSessionId = null;
console.log('Chat state reset complete');
}
// Load Chat View
async function loadChatView() {
console.log('[loadChatView] Loading chat view...');
// Reset state on view load to prevent stale session references
resetChatState();
// Load chat sessions
try {
console.log('[loadChatView] Fetching sessions...');
const res = await fetch('/claude/api/claude/sessions');
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${await res.text()}`);
}
const data = await res.json();
console.log('[loadChatView] Sessions data received:', data);
const sessionsListEl = document.getElementById('chat-history-list');
if (!sessionsListEl) {
console.error('[loadChatView] chat-history-list element not found!');
return;
}
// ONLY show active sessions - no historical sessions in chat view
// Historical sessions are read-only and can't receive new messages
const activeSessions = (data.active || []).filter(s => s.status === 'running');
console.log('Active sessions (can receive messages):', activeSessions.length);
if (activeSessions.length > 0) {
sessionsListEl.innerHTML = activeSessions.map(session => {
const projectName = session.metadata && session.metadata.project ?
session.metadata.project :
session.id.substring(0, 20);
return `
<div class="chat-history-item ${session.id === attachedSessionId ? 'active' : ''}"
onclick="attachToSession('${session.id}')">
<div class="chat-history-icon">💬</div>
<div class="chat-history-content">
<div class="chat-history-title">${projectName}</div>
<div class="chat-history-meta">
<span class="chat-history-date">${new Date(session.createdAt).toLocaleDateString()}</span>
<span class="chat-history-status active">Running</span>
</div>
</div>
</div>
`;
}).join('');
} else {
sessionsListEl.innerHTML = `
<div class="chat-history-empty">
<p>No active sessions</p>
<button class="btn-primary" onclick="startNewChat()" style="margin-top: 12px;">+ Start New Chat</button>
</div>
`;
}
console.log('[loadChatView] Chat view loaded successfully');
} catch (error) {
console.error('[loadChatView] Error loading chat sessions:', error);
const sessionsListEl = document.getElementById('chat-history-list');
if (sessionsListEl) {
sessionsListEl.innerHTML = `
<div class="chat-history-empty">
<p>Error: ${error.message}</p>
<button class="btn-secondary" onclick="loadChatView()" style="margin-top: 12px;">Retry</button>
</div>
`;
}
}
}
// Start New Chat
async function startNewChat() {
// Reset all state first
resetChatState();
// Clear current chat
clearChatDisplay();
appendSystemMessage('Creating new chat session...');
// Create new session
try {
console.log('Creating new Claude Code session...');
const res = await fetch('/claude/api/claude/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workingDir: '/home/uroma/obsidian-vault',
metadata: { type: 'chat', source: 'web-ide' }
})
});
const data = await res.json();
console.log('Session creation response:', data);
if (data.success) {
attachedSessionId = data.session.id;
chatSessionId = data.session.id;
console.log('New session created:', data.session.id);
// Update UI
document.getElementById('current-session-id').textContent = data.session.id;
document.getElementById('chat-title').textContent = 'New Chat';
// Subscribe to session via WebSocket
subscribeToSession(data.session.id);
// Reload sessions list
loadChatView();
// Show success message
appendSystemMessage('✅ New chat session started! You can now chat with Claude Code.');
} else {
console.error('Session creation failed:', data);
appendSystemMessage('❌ Failed to create session: ' + (data.error || 'Unknown error'));
}
} catch (error) {
console.error('Error starting new chat:', error);
appendSystemMessage('❌ Failed to start new chat session: ' + error.message);
}
}
// Attach to Existing Session
function attachToSession(sessionId) {
attachedSessionId = sessionId;
chatSessionId = sessionId;
// Update UI
document.getElementById('current-session-id').textContent = sessionId;
// Load session messages
loadSessionMessages(sessionId);
// Subscribe to session via WebSocket
subscribeToSession(sessionId);
// Update active state in sidebar
document.querySelectorAll('.chat-session-item').forEach(item => {
item.classList.remove('active');
if (item.getAttribute('onclick') && item.getAttribute('onclick').includes(sessionId)) {
item.classList.add('active');
}
});
appendSystemMessage('Attached to session: ' + sessionId);
}
// Subscribe to session via WebSocket
function subscribeToSession(sessionId) {
if (window.ws && window.ws.readyState === WebSocket.OPEN) {
window.ws.send(JSON.stringify({
type: 'subscribe',
sessionId: sessionId
}));
console.log('Subscribed to session:', sessionId);
}
}
// Load Session Messages
async function loadSessionMessages(sessionId) {
try {
const res = await fetch('/claude/api/claude/sessions/' + sessionId);
const data = await res.json();
if (data.session) {
clearChatDisplay();
// Add existing messages from output buffer
data.session.outputBuffer.forEach(entry => {
appendMessage('assistant', entry.content, false);
});
}
} catch (error) {
console.error('Error loading session messages:', error);
}
}
// Handle Chat Key Press
function handleChatKeypress(event) {
const input = document.getElementById('chat-input');
// Update character count
const charCount = input.value.length;
document.getElementById('char-count').textContent = charCount + ' characters';
// Send on Enter (but allow Shift+Enter for new line)
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
sendChatMessage();
}
// Auto-resize textarea
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 150) + 'px';
}
// Send Chat Message
async function sendChatMessage() {
const input = document.getElementById('chat-input');
const message = input.value.trim();
if (!message) return;
if (!attachedSessionId) {
appendSystemMessage('Please start or attach to a session first.');
return;
}
// Add user message to chat
appendMessage('user', message);
clearInput();
// Show streaming indicator
showStreamingIndicator();
try {
// Check WebSocket state
if (!window.ws) {
console.error('WebSocket is null/undefined');
appendSystemMessage('WebSocket not initialized. Please refresh the page.');
hideStreamingIndicator();
return;
}
const state = window.ws.readyState;
const stateName = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'][state] || 'UNKNOWN';
console.log('WebSocket state:', state, stateName);
if (state !== WebSocket.OPEN) {
console.error('WebSocket not in OPEN state:', stateName);
appendSystemMessage(`WebSocket not ready (state: ${stateName}). Retrying...`);
hideStreamingIndicator();
// Trigger reconnection if closed
if (state === WebSocket.CLOSED) {
console.log('WebSocket closed, triggering reconnection...');
if (typeof connectWebSocket === 'function') {
connectWebSocket();
}
}
return;
}
// Send command via WebSocket
window.ws.send(JSON.stringify({
type: 'command',
sessionId: attachedSessionId,
command: message
}));
console.log('Sent command via WebSocket:', message.substring(0, 50));
} catch (error) {
console.error('Error sending message:', error);
hideStreamingIndicator();
appendSystemMessage('Failed to send message: ' + error.message);
}
}
// Append Message to Chat
function appendMessage(role, content, scroll) {
const messagesContainer = document.getElementById('chat-messages');
// Remove welcome message if present
const welcome = messagesContainer.querySelector('.chat-welcome');
if (welcome) {
welcome.remove();
}
// Remove streaming indicator if present
const streaming = messagesContainer.querySelector('.streaming-indicator');
if (streaming) {
streaming.remove();
}
const messageDiv = document.createElement('div');
messageDiv.className = 'chat-message ' + role;
const avatar = document.createElement('div');
avatar.className = 'chat-message-avatar';
avatar.textContent = role === 'user' ? '👤' : '🤖';
const contentDiv = document.createElement('div');
contentDiv.className = 'chat-message-content';
const bubble = document.createElement('div');
bubble.className = 'chat-message-bubble';
// Format content (handle code blocks, etc.)
bubble.innerHTML = formatMessage(content);
const timestamp = document.createElement('div');
timestamp.className = 'chat-message-timestamp';
timestamp.textContent = new Date().toLocaleTimeString();
contentDiv.appendChild(bubble);
contentDiv.appendChild(timestamp);
messageDiv.appendChild(avatar);
messageDiv.appendChild(contentDiv);
messagesContainer.appendChild(messageDiv);
// Scroll to bottom
if (scroll) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// Update token usage (estimated)
updateTokenUsage(content.length);
}
// Append System Message
function appendSystemMessage(text) {
const messagesContainer = document.getElementById('chat-messages');
// Remove welcome message if present
const welcome = messagesContainer.querySelector('.chat-welcome');
if (welcome) {
welcome.remove();
}
const systemDiv = document.createElement('div');
systemDiv.className = 'chat-message assistant';
systemDiv.style.opacity = '0.8';
const avatar = document.createElement('div');
avatar.className = 'chat-message-avatar';
avatar.textContent = '';
const contentDiv = document.createElement('div');
contentDiv.className = 'chat-message-content';
const bubble = document.createElement('div');
bubble.className = 'chat-message-bubble';
bubble.innerHTML = '<em>' + escapeHtml(text) + '</em>';
contentDiv.appendChild(bubble);
systemDiv.appendChild(avatar);
systemDiv.appendChild(contentDiv);
messagesContainer.appendChild(systemDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// Format Message (handle code blocks, markdown, etc.)
function formatMessage(content) {
// Escape HTML first
let formatted = escapeHtml(content);
// Handle code blocks
formatted = formatted.replace(/```(\w+)?\n([\s\S]*?)```/g, function(match, lang, code) {
return '<pre><code class="language-' + (lang || 'text') + '">' + code.trim() + '</code></pre>';
});
// Handle inline code
formatted = formatted.replace(/`([^`]+)`/g, '<code>$1</code>');
// Handle line breaks
formatted = formatted.replace(/\n/g, '<br>');
return formatted;
}
// Show Streaming Indicator
function showStreamingIndicator() {
const messagesContainer = document.getElementById('chat-messages');
// Remove existing streaming indicator
const existing = messagesContainer.querySelector('.streaming-indicator');
if (existing) {
existing.remove();
}
const streamingDiv = document.createElement('div');
streamingDiv.className = 'streaming-indicator';
streamingDiv.innerHTML = '<div class="streaming-dot"></div><div class="streaming-dot"></div><div class="streaming-dot"></div>';
messagesContainer.appendChild(streamingDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// Hide Streaming Indicator
function hideStreamingIndicator() {
const streaming = document.querySelector('.streaming-indicator');
if (streaming) {
streaming.remove();
}
}
// Clear Chat Display
function clearChatDisplay() {
const messagesContainer = document.getElementById('chat-messages');
messagesContainer.innerHTML = '';
// Reset token usage
document.getElementById('token-usage').textContent = '0 tokens used';
}
// Clear Chat
function clearChat() {
if (confirm('Clear all messages in this chat?')) {
clearChatDisplay();
// Show welcome message again
const messagesContainer = document.getElementById('chat-messages');
messagesContainer.innerHTML = `
<div class="chat-welcome">
<h2>👋 Chat Cleared</h2>
<p>Start a new conversation with Claude Code.</p>
</div>
`;
}
}
// Clear Input
function clearInput() {
const input = document.getElementById('chat-input');
input.value = '';
input.style.height = 'auto';
document.getElementById('char-count').textContent = '0 characters';
}
// Update Token Usage
function updateTokenUsage(charCount) {
// Rough estimation: ~4 characters per token
const estimatedTokens = Math.ceil(charCount / 4);
const currentUsage = parseInt(document.getElementById('token-usage').textContent) || 0;
document.getElementById('token-usage').textContent = (currentUsage + estimatedTokens) + ' tokens used';
}
// Show Attach CLI Modal
function showAttachCliModal() {
document.getElementById('modal-overlay').classList.remove('hidden');
document.getElementById('attach-cli-modal').classList.remove('hidden');
}
// Submit Attach CLI Session
async function submitAttachCliSession() {
const sessionId = document.getElementById('cli-session-id').value.trim();
if (!sessionId) {
alert('Please enter a session ID');
return;
}
attachToSession(sessionId);
closeModal();
}
// Attach File (placeholder for now)
function attachFile() {
appendSystemMessage('File attachment feature coming soon! For now, use @filename to reference files.');
}
// Show Chat Settings (placeholder)
function showChatSettings() {
appendSystemMessage('Chat settings coming soon!');
}
// Export variables to window for global access
if (typeof window !== 'undefined') {
window.attachedSessionId = attachedSessionId;
window.chatSessionId = chatSessionId;
window.chatMessages = chatMessages;
// Create a proxy to keep window vars in sync
Object.defineProperty(window, 'attachedSessionId', {
get: function() { return attachedSessionId; },
set: function(value) { attachedSessionId = value; }
});
Object.defineProperty(window, 'chatSessionId', {
get: function() { return chatSessionId; },
set: function(value) { chatSessionId = value; }
});
}

1098
public/claude-ide/ide.css Normal file

File diff suppressed because it is too large Load Diff

834
public/claude-ide/ide.js Normal file
View File

@@ -0,0 +1,834 @@
// Claude Code IDE JavaScript
let currentSession = null;
let ws = null;
// Make ws globally accessible for other scripts
Object.defineProperty(window, 'ws', {
get: function() { return ws; },
set: function(value) { ws = value; },
enumerable: true,
configurable: true
});
// Initialize
document.addEventListener('DOMContentLoaded', () => {
initNavigation();
connectWebSocket();
// Check URL params for session and prompt
const urlParams = new URLSearchParams(window.location.search);
const sessionId = urlParams.get('session');
const prompt = urlParams.get('prompt');
if (sessionId || prompt) {
// Switch to chat view first
switchView('chat');
// Wait for chat to load, then handle session/prompt
setTimeout(() => {
if (sessionId) {
attachToSession(sessionId);
}
if (prompt) {
setTimeout(() => {
const input = document.getElementById('chat-input');
if (input) {
input.value = decodeURIComponent(prompt);
sendChatMessage();
}
}, 1000);
}
}, 500);
} else {
// Default to chat view
switchView('chat');
}
});
// Navigation
function initNavigation() {
const navItems = document.querySelectorAll('.nav-item');
navItems.forEach(item => {
item.addEventListener('click', () => {
const view = item.dataset.view;
switchView(view);
});
});
}
function switchView(viewName) {
// Update nav items
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.remove('active');
if (item.dataset.view === viewName) {
item.classList.add('active');
}
});
// Update views
document.querySelectorAll('.view').forEach(view => {
view.classList.remove('active');
});
document.getElementById(`${viewName}-view`).classList.add('active');
// Load content for the view
switch(viewName) {
case 'dashboard':
loadDashboard();
break;
case 'chat':
loadChatView();
break;
case 'sessions':
loadSessions();
break;
case 'projects':
loadProjects();
break;
case 'files':
loadFiles();
break;
case 'terminal':
loadTerminal();
break;
}
}
// WebSocket Connection
function connectWebSocket() {
const wsUrl = `wss://${window.location.host}/claude/api/claude/chat`;
console.log('Connecting to WebSocket:', wsUrl);
window.ws = new WebSocket(wsUrl);
window.ws.onopen = () => {
console.log('WebSocket connected, readyState:', window.ws.readyState);
// Send a test message to verify connection
try {
window.ws.send(JSON.stringify({ type: 'ping' }));
} catch (error) {
console.error('Error sending ping:', error);
}
};
window.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('WebSocket message received:', data.type);
handleWebSocketMessage(data);
};
window.ws.onerror = (error) => {
console.error('WebSocket error:', error);
console.log('WebSocket error details:', {
type: error.type,
target: error.target,
readyState: window.ws?.readyState
});
};
window.ws.onclose = (event) => {
console.log('WebSocket disconnected:', {
code: event.code,
reason: event.reason,
wasClean: event.wasClean
});
// Clear the ws reference
window.ws = null;
// Attempt to reconnect after 5 seconds
setTimeout(() => {
console.log('Attempting to reconnect...');
connectWebSocket();
}, 5000);
};
}
function handleWebSocketMessage(data) {
switch(data.type) {
case 'connected':
console.log(data.message);
break;
case 'output':
handleSessionOutput(data);
break;
case 'operations-detected':
handleOperationsDetected(data);
break;
case 'operations-executed':
handleOperationsExecuted(data);
break;
case 'operations-error':
handleOperationsError(data);
break;
case 'operation-progress':
handleOperationProgress(data);
break;
case 'error':
console.error('WebSocket error:', data.error);
// Show error in chat if attached
if (typeof appendSystemMessage === 'function') {
appendSystemMessage('Error: ' + data.error);
}
break;
}
}
/**
* Handle operations detected event
*/
function handleOperationsDetected(data) {
console.log('Operations detected:', data.operations.length);
// Only show if we're attached to this session
if (data.sessionId !== attachedSessionId) return;
// Store response for execution
window.currentOperationsResponse = data.response;
// Use tag renderer to show operations panel
if (typeof tagRenderer !== 'undefined') {
tagRenderer.showOperationsPanel(data.operations, data.response);
}
}
/**
* Handle operations executed event
*/
function handleOperationsExecuted(data) {
console.log('Operations executed:', data.results);
// Only handle if we're attached to this session
if (data.sessionId !== attachedSessionId) return;
// Hide progress and show completion
if (typeof tagRenderer !== 'undefined') {
tagRenderer.hideProgress();
tagRenderer.hideOperationsPanel();
tagRenderer.showCompletion(data.results);
}
}
/**
* Handle operations error event
*/
function handleOperationsError(data) {
console.error('Operations error:', data.error);
// Only handle if we're attached to this session
if (data.sessionId !== attachedSessionId) return;
// Show error
if (typeof tagRenderer !== 'undefined') {
tagRenderer.hideProgress();
tagRenderer.showError(data.error);
}
}
/**
* Handle operation progress event
*/
function handleOperationProgress(data) {
console.log('Operation progress:', data.progress);
// Only handle if we're attached to this session
if (data.sessionId !== attachedSessionId) return;
// Update progress
if (typeof tagRenderer !== 'undefined') {
const progress = data.progress;
let message = '';
switch(progress.type) {
case 'write':
message = `Creating ${progress.path}...`;
break;
case 'rename':
message = `Renaming ${progress.from} to ${progress.to}...`;
break;
case 'delete':
message = `Deleting ${progress.path}...`;
break;
case 'install':
message = `Installing packages: ${progress.packages.join(', ')}...`;
break;
case 'command':
message = `Executing command: ${progress.command}...`;
break;
default:
message = 'Processing...';
}
tagRenderer.updateProgress(message);
}
}
function handleSessionOutput(data) {
// Handle output for sessions view
if (currentSession && data.sessionId === currentSession.id) {
appendOutput(data.data);
}
// Handle output for chat view
if (typeof attachedSessionId !== 'undefined' && data.sessionId === attachedSessionId) {
// Hide streaming indicator
if (typeof hideStreamingIndicator === 'function') {
hideStreamingIndicator();
}
// Append output as assistant message
if (typeof appendMessage === 'function') {
appendMessage('assistant', data.data.content, true);
}
}
}
// Dashboard
async function loadDashboard() {
try {
// Load stats
const [sessionsRes, projectsRes] = await Promise.all([
fetch('/claude/api/claude/sessions'),
fetch('/claude/api/claude/projects')
]);
const sessionsData = await sessionsRes.json();
const projectsData = await projectsRes.json();
// Update stats
document.getElementById('active-sessions-count').textContent =
sessionsData.active?.length || 0;
document.getElementById('historical-sessions-count').textContent =
sessionsData.historical?.length || 0;
document.getElementById('total-projects-count').textContent =
projectsData.projects?.length || 0;
// Update active sessions list
const activeSessionsEl = document.getElementById('active-sessions-list');
if (sessionsData.active && sessionsData.active.length > 0) {
activeSessionsEl.innerHTML = sessionsData.active.map(session => `
<div class="session-item" onclick="viewSession('${session.id}')">
<div class="session-header">
<span class="session-id">${session.id.substring(0, 20)}...</span>
<span class="session-status ${session.status}">${session.status}</span>
</div>
<div class="session-meta">
Working: ${session.workingDir}<br>
Created: ${new Date(session.createdAt).toLocaleString()}
</div>
</div>
`).join('');
} else {
activeSessionsEl.innerHTML = '<p class="placeholder">No active sessions</p>';
}
// Update projects list
const projectsEl = document.getElementById('recent-projects-list');
if (projectsData.projects && projectsData.projects.length > 0) {
projectsEl.innerHTML = projectsData.projects.slice(0, 5).map(project => `
<div class="project-card" onclick="viewProject('${project.name}')">
<h3>${project.name}</h3>
<p class="project-meta">
Modified: ${new Date(project.modified).toLocaleDateString()}
</p>
</div>
`).join('');
} else {
projectsEl.innerHTML = '<p class="placeholder">No projects yet</p>';
}
} catch (error) {
console.error('Error loading dashboard:', error);
}
}
function refreshSessions() {
loadDashboard();
}
// Sessions
async function loadSessions() {
try {
const res = await fetch('/claude/api/claude/sessions');
const data = await res.json();
const sessionsListEl = document.getElementById('sessions-list');
const allSessions = [
...(data.active || []),
...(data.historical || [])
];
if (allSessions.length > 0) {
sessionsListEl.innerHTML = allSessions.map(session => `
<div class="session-item" onclick="viewSession('${session.id}')">
<div class="session-header">
<span class="session-id">${session.id.substring(0, 20)}...</span>
<span class="session-status ${session.status}">${session.status}</span>
</div>
<div class="session-meta">
${session.workingDir}<br>
${new Date(session.createdAt).toLocaleString()}
</div>
</div>
`).join('');
} else {
sessionsListEl.innerHTML = '<p class="placeholder">No sessions</p>';
}
} catch (error) {
console.error('Error loading sessions:', error);
}
}
async function viewSession(sessionId) {
try {
const res = await fetch(`/claude/api/claude/sessions/${sessionId}`);
const data = await res.json();
currentSession = data.session;
const detailEl = document.getElementById('session-detail');
detailEl.innerHTML = `
<div class="session-header-info">
<h2>${data.session.id}</h2>
<p>Status: <span class="session-status ${data.session.status}">${data.session.status}</span></p>
<p>PID: ${data.session.pid || 'N/A'}</p>
<p>Working Directory: ${data.session.workingDir}</p>
<p>Created: ${new Date(data.session.createdAt).toLocaleString()}</p>
</div>
<h3>Context Usage</h3>
<div class="context-bar">
<div class="context-fill" style="width: ${data.session.context.totalTokens / data.session.context.maxTokens * 100}%"></div>
</div>
<div class="context-stats">
<span>${data.session.context.totalTokens.toLocaleString()} tokens</span>
<span>${Math.round(data.session.context.totalTokens / data.session.context.maxTokens * 100)}% used</span>
</div>
<h3>Session Output</h3>
<div class="session-output" id="session-output">
${data.session.outputBuffer.map(entry => `
<div class="output-line ${entry.type}">${escapeHtml(entry.content)}</div>
`).join('')}
</div>
${data.session.status === 'running' ? `
<div class="command-input-container">
<input type="text" id="command-input" class="command-input" placeholder="Enter command..." onkeypress="handleCommandKeypress(event)">
<button class="btn-primary" onclick="sendCommand()">Send</button>
</div>
` : ''}
`;
// Switch to sessions view
switchView('sessions');
} catch (error) {
console.error('Error viewing session:', error);
alert('Failed to load session');
}
}
function handleCommandKeypress(event) {
if (event.key === 'Enter') {
sendCommand();
}
}
async function sendCommand() {
const input = document.getElementById('command-input');
const command = input.value.trim();
if (!command || !currentSession) return;
try {
await fetch(`/claude/api/claude/sessions/${currentSession.id}/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command })
});
input.value = '';
// Append command to output
appendOutput({
type: 'command',
content: `$ ${command}\n`
});
} catch (error) {
console.error('Error sending command:', error);
alert('Failed to send command');
}
}
function appendOutput(data) {
const outputEl = document.getElementById('session-output');
if (outputEl) {
const line = document.createElement('div');
line.className = `output-line ${data.type}`;
line.textContent = data.content;
outputEl.appendChild(line);
outputEl.scrollTop = outputEl.scrollHeight;
}
}
// Projects
async function loadProjects() {
try {
const res = await fetch('/claude/api/claude/projects');
const data = await res.json();
const gridEl = document.getElementById('projects-grid');
if (data.projects && data.projects.length > 0) {
gridEl.innerHTML = data.projects.map(project => `
<div class="project-card" onclick="viewProject('${project.name}')">
<h3>${project.name}</h3>
<p class="project-meta">
Modified: ${new Date(project.modified).toLocaleString()}
</p>
<p class="project-description">Click to view project details</p>
</div>
`).join('');
} else {
gridEl.innerHTML = '<p class="placeholder">No projects yet. Create your first project!</p>';
}
} catch (error) {
console.error('Error loading projects:', error);
}
}
async function viewProject(projectName) {
// Open the project file in the files view
const path = `Claude Projects/${projectName}.md`;
loadFileContent(path);
switchView('files');
}
// Files
async function loadFiles() {
try {
const res = await fetch('/claude/api/files');
const data = await res.json();
const treeEl = document.getElementById('file-tree');
treeEl.innerHTML = renderFileTree(data.tree);
} catch (error) {
console.error('Error loading files:', error);
}
}
async function loadTerminal() {
// Initialize terminal manager if not already done
if (!window.terminalManager) {
window.terminalManager = new TerminalManager();
await window.terminalManager.initialize();
}
// Set up new terminal button
const btnNewTerminal = document.getElementById('btn-new-terminal');
if (btnNewTerminal) {
btnNewTerminal.onclick = () => {
window.terminalManager.createTerminal();
};
}
}
function renderFileTree(tree, level = 0) {
return tree.map(item => {
const padding = level * 1 + 0.5;
const icon = item.type === 'folder' ? '📁' : '📄';
if (item.type === 'folder' && item.children) {
return `
<div style="padding-left: ${padding}rem">
<div class="tree-item folder" onclick="toggleFolder(this)">
<span>${icon}</span>
<span>${item.name}</span>
</div>
<div class="tree-children" style="display: none;">
${renderFileTree(item.children, level + 1)}
</div>
</div>
`;
} else {
return `
<div style="padding-left: ${padding}rem">
<div class="tree-item file" onclick="loadFile('${item.path}')">
<span>${icon}</span>
<span>${item.name}</span>
</div>
</div>
`;
}
}).join('');
}
function toggleFolder(element) {
const children = element.parentElement.querySelector('.tree-children');
const icon = element.querySelector('span:first-child');
if (children.style.display === 'none') {
children.style.display = 'block';
icon.textContent = '📂';
} else {
children.style.display = 'none';
icon.textContent = '📁';
}
}
async function loadFile(filePath) {
try {
const res = await fetch(`/claude/api/file/${encodeURIComponent(filePath)}`);
const data = await res.json();
const isHtmlFile = filePath.toLowerCase().endsWith('.html') || filePath.toLowerCase().endsWith('.htm');
const editorEl = document.getElementById('file-editor');
if (isHtmlFile) {
// HTML file - show with preview option
editorEl.innerHTML = `
<div class="file-header">
<h2>${filePath}</h2>
<div class="file-actions">
<button class="btn-secondary btn-sm" onclick="editFile('${filePath}')">Edit</button>
<button class="btn-primary btn-sm" onclick="showHtmlPreview('${filePath}')">👁️ Preview</button>
</div>
</div>
<div class="file-content" id="file-content-view">
<div class="view-toggle">
<button class="toggle-btn active" data-view="code" onclick="switchFileView('code')">Code</button>
<button class="toggle-btn" data-view="preview" onclick="switchFileView('preview')">Preview</button>
</div>
<div class="code-view">
<pre><code class="language-html">${escapeHtml(data.content)}</code></pre>
</div>
<div class="preview-view" style="display: none;">
<iframe id="html-preview-frame" sandbox="allow-scripts allow-same-origin allow-forms"></iframe>
</div>
</div>
`;
// Store file content for preview
window.currentFileContent = data.content;
window.currentFilePath = filePath;
// Highlight code
if (window.hljs) {
document.querySelectorAll('#file-content-view pre code').forEach((block) => {
hljs.highlightElement(block);
});
}
} else {
// Non-HTML file - show as before
editorEl.innerHTML = `
<div class="file-header">
<h2>${filePath}</h2>
<div class="file-actions">
<button class="btn-secondary btn-sm" onclick="editFile('${filePath}')">Edit</button>
</div>
</div>
<div class="file-content">
<div class="markdown-body">${data.html}</div>
</div>
`;
}
} catch (error) {
console.error('Error loading file:', error);
}
}
async function loadFileContent(filePath) {
await loadFile(filePath);
switchView('files');
}
// HTML Preview Functions
function showHtmlPreview(filePath) {
switchFileView('preview');
}
function switchFileView(view) {
const codeView = document.querySelector('.code-view');
const previewView = document.querySelector('.preview-view');
const toggleBtns = document.querySelectorAll('.toggle-btn');
// Update buttons
toggleBtns.forEach(btn => {
btn.classList.remove('active');
if (btn.dataset.view === view) {
btn.classList.add('active');
}
});
// Show/hide views
if (view === 'code') {
codeView.style.display = 'block';
previewView.style.display = 'none';
} else if (view === 'preview') {
codeView.style.display = 'none';
previewView.style.display = 'block';
// Load HTML into iframe using blob URL
const iframe = document.getElementById('html-preview-frame');
if (iframe && window.currentFileContent) {
// Create blob URL from HTML content
const blob = new Blob([window.currentFileContent], { type: 'text/html' });
const blobUrl = URL.createObjectURL(blob);
// Load blob URL in iframe
iframe.src = blobUrl;
// Clean up blob URL when iframe is unloaded
iframe.onload = () => {
// Keep the blob URL active while preview is shown
};
}
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Modals
function createNewSession() {
document.getElementById('modal-overlay').classList.remove('hidden');
document.getElementById('new-session-modal').classList.remove('hidden');
}
function createNewProject() {
document.getElementById('modal-overlay').classList.remove('hidden');
document.getElementById('new-project-modal').classList.remove('hidden');
}
function closeModal() {
document.getElementById('modal-overlay').classList.add('hidden');
document.querySelectorAll('.modal').forEach(modal => {
modal.classList.add('hidden');
});
}
async function submitNewSession() {
const workingDir = document.getElementById('session-working-dir').value;
const project = document.getElementById('session-project').value;
try {
const res = await fetch('/claude/api/claude/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workingDir,
metadata: { project }
})
});
const data = await res.json();
if (data.success) {
closeModal();
viewSession(data.session.id);
}
} catch (error) {
console.error('Error creating session:', error);
alert('Failed to create session');
}
}
async function submitNewProject() {
const name = document.getElementById('project-name').value;
const description = document.getElementById('project-description').value;
const type = document.getElementById('project-type').value;
if (!name) {
alert('Please enter a project name');
return;
}
try {
const res = await fetch('/claude/api/claude/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, type })
});
const data = await res.json();
if (data.success) {
closeModal();
loadProjects();
viewProject(name);
}
} catch (error) {
console.error('Error creating project:', error);
alert('Failed to create project');
}
}
// Utility functions
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Show toast notification
* @param {string} message - The message to display
* @param {string} type - The type of toast: 'success', 'error', 'info'
* @param {number} duration - Duration in milliseconds (default: 3000)
*/
function showToast(message, type = 'info', duration = 3000) {
// Remove existing toasts
const existingToasts = document.querySelectorAll('.toast-notification');
existingToasts.forEach(toast => toast.remove());
// Create toast element
const toast = document.createElement('div');
toast.className = `toast-notification toast-${type}`;
toast.innerHTML = `
<span class="toast-icon">${getToastIcon(type)}</span>
<span class="toast-message">${escapeHtml(message)}</span>
`;
document.body.appendChild(toast);
// Trigger animation
setTimeout(() => {
toast.classList.add('visible');
}, 10);
// Auto remove after duration
setTimeout(() => {
toast.classList.remove('visible');
setTimeout(() => {
toast.remove();
}, 300);
}, duration);
}
/**
* Get toast icon based on type
*/
function getToastIcon(type) {
const icons = {
success: '✓',
error: '✕',
info: '',
warning: '⚠'
};
return icons[type] || icons.info;
}
function showProjects() {
switchView('projects');
}
// Logout
document.getElementById('logout-btn')?.addEventListener('click', async () => {
try {
await fetch('/claude/api/logout', { method: 'POST' });
window.location.reload();
} catch (error) {
console.error('Error logging out:', error);
}
});

View File

@@ -0,0 +1,316 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Claude Code IDE</title>
<link rel="stylesheet" href="/claude/css/style.css">
<link rel="stylesheet" href="/claude/claude-ide/ide.css">
<link rel="stylesheet" href="/claude/claude-ide/tag-renderer.css">
<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="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
</head>
<body>
<div id="app">
<!-- Navigation -->
<nav class="navbar">
<div class="nav-brand">
<h1>Claude Code IDE</h1>
</div>
<div class="nav-menu">
<button class="nav-item active" data-view="dashboard">Dashboard</button>
<button class="nav-item" data-view="chat">💬 Chat</button>
<button class="nav-item" data-view="sessions">Sessions</button>
<button class="nav-item" data-view="projects">Projects</button>
<button class="nav-item" data-view="files">Files</button>
<button class="nav-item" data-view="terminal">🖥️ Terminal</button>
</div>
<div class="nav-user">
<button id="logout-btn" class="btn-secondary">Logout</button>
</div>
</nav>
<!-- Dashboard View -->
<div id="dashboard-view" class="view active">
<div class="dashboard-grid">
<!-- Stats Cards -->
<div class="stat-card">
<h3>Active Sessions</h3>
<div class="stat-value" id="active-sessions-count">0</div>
</div>
<div class="stat-card">
<h3>Total Projects</h3>
<div class="stat-value" id="total-projects-count">0</div>
</div>
<div class="stat-card">
<h3>Historical Sessions</h3>
<div class="stat-value" id="historical-sessions-count">0</div>
</div>
<div class="stat-card">
<h3>Quick Actions</h3>
<div class="stat-actions">
<button class="btn-primary" onclick="createNewSession()">New Session</button>
<button class="btn-secondary" onclick="createNewProject()">New Project</button>
</div>
</div>
<!-- Active Sessions Panel -->
<div class="panel">
<div class="panel-header">
<h2>Active Sessions</h2>
<button class="btn-secondary btn-sm" onclick="refreshSessions()">Refresh</button>
</div>
<div class="panel-content" id="active-sessions-list">
<div class="loading">Loading...</div>
</div>
</div>
<!-- Recent Projects Panel -->
<div class="panel">
<div class="panel-header">
<h2>Recent Projects</h2>
<button class="btn-secondary btn-sm" onclick="showProjects()">View All</button>
</div>
<div class="panel-content" id="recent-projects-list">
<div class="loading">Loading...</div>
</div>
</div>
</div>
</div>
<!-- Sessions View -->
<div id="sessions-view" class="view">
<div class="sessions-layout">
<div class="sessions-sidebar">
<div class="sidebar-header">
<h2>Sessions</h2>
<button class="btn-primary" onclick="createNewSession()">+ New</button>
</div>
<div class="sessions-list" id="sessions-list">
<div class="loading">Loading...</div>
</div>
</div>
<div class="sessions-main">
<div id="session-detail" class="session-detail">
<div class="placeholder">
<h2>Select a session</h2>
<p>Choose a session from the sidebar to view details</p>
</div>
</div>
</div>
</div>
</div>
<!-- Projects View -->
<div id="projects-view" class="view">
<div class="projects-header">
<h2>Projects</h2>
<button class="btn-primary" onclick="createNewProject()">+ New Project</button>
</div>
<div class="projects-grid" id="projects-grid">
<div class="loading">Loading...</div>
</div>
</div>
<!-- Chat View -->
<div id="chat-view" class="view">
<div class="chat-layout">
<div class="chat-sidebar">
<div class="sidebar-header">
<h2>Chat</h2>
<button class="btn-primary" onclick="startNewChat()">+ New</button>
</div>
<div class="chat-history-list" id="chat-history-list">
<div class="loading">Loading...</div>
</div>
</div>
<div class="chat-main">
<div class="chat-header" id="chat-header">
<div class="chat-session-info">
<h2 id="chat-title">New Chat</h2>
<span class="chat-session-id" id="current-session-id"></span>
</div>
<div class="chat-actions">
<button class="btn-secondary btn-sm" onclick="clearChat()" title="Clear chat">Clear</button>
<button class="btn-secondary btn-sm" onclick="showChatSettings()" title="Settings">⚙️</button>
</div>
</div>
<div class="chat-messages" id="chat-messages">
<div class="chat-welcome">
<h2>👋 Welcome to Claude Code Chat!</h2>
<p>Start a conversation with Claude Code. Your session will be saved automatically.</p>
<div class="chat-tips">
<h3>Quick Tips:</h3>
<ul>
<li>Type your message and press Enter to send</li>
<li>Shift+Enter for a new line</li>
<li>Use <code>/help</code> to see available commands</li>
<li>Attach files from your vault using <code>@filename</code></li>
</ul>
</div>
<div class="chat-connection-info">
<h3>💡 Pro Tip: Continue from CLI</h3>
<p>To continue a CLI session in the web interface:</p>
<ol>
<li>In your terminal, note the session ID shown by Claude Code</li>
<li>Click "Attach CLI Session" below</li>
<li>Enter the session ID to connect</li>
</ol>
<button class="btn-secondary" onclick="showAttachCliModal()" style="margin-top: 1rem;">Attach CLI Session</button>
</div>
</div>
</div>
<div class="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)"
rows="1"
onkeydown="handleChatKeypress(event)"></textarea>
<div class="chat-input-actions">
<button class="btn-icon" onclick="attachFile()" title="Attach file">📎</button>
<button class="btn-primary btn-send" onclick="sendChatMessage()">Send</button>
</div>
</div>
<div class="chat-input-info">
<span class="token-usage" id="token-usage">0 tokens used</span>
<span class="char-count" id="char-count">0 characters</span>
</div>
</div>
</div>
</div>
</div>
<!-- Files View -->
<div id="files-view" class="view">
<div class="files-layout">
<div class="files-sidebar">
<div class="sidebar-header">
<h2>Files</h2>
</div>
<div class="search-box">
<input type="text" id="file-search" placeholder="Search files...">
</div>
<div class="file-tree" id="file-tree">
<div class="loading">Loading...</div>
</div>
</div>
<div class="files-main">
<div id="file-editor" class="file-editor">
<div class="placeholder">
<h2>Select a file</h2>
<p>Choose a file from the sidebar to view and edit</p>
</div>
</div>
</div>
</div>
</div>
<!-- Terminal View -->
<div id="terminal-view" class="view">
<div class="terminal-layout">
<div class="terminal-header">
<h2>🖥️ Terminals</h2>
<button class="btn-primary" id="btn-new-terminal">+ New Terminal</button>
</div>
<div class="terminal-tabs" id="terminal-tabs">
<!-- Terminal tabs will be added here -->
</div>
<div class="terminals-container" id="terminals-container">
<!-- Terminal instances will be added here -->
<div class="terminal-placeholder">
<h3>No terminals open</h3>
<p>Click "+ New Terminal" to get started</p>
</div>
</div>
</div>
</div>
</div>
<!-- Modals -->
<div id="modal-overlay" class="modal-overlay hidden">
<div id="new-session-modal" class="modal hidden">
<div class="modal-header">
<h2>New Session</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Working Directory</label>
<input type="text" id="session-working-dir" value="/home/uroma/obsidian-vault">
</div>
<div class="form-group">
<label>Project (optional)</label>
<input type="text" id="session-project" placeholder="e.g., DedicatedNodes">
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeModal()">Cancel</button>
<button class="btn-primary" onclick="submitNewSession()">Create</button>
</div>
</div>
<div id="new-project-modal" class="modal hidden">
<div class="modal-header">
<h2>New Project</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Project Name</label>
<input type="text" id="project-name" placeholder="My Project">
</div>
<div class="form-group">
<label>Description</label>
<textarea id="project-description" rows="3" placeholder="Project description..."></textarea>
</div>
<div class="form-group">
<label>Type</label>
<select id="project-type">
<option value="general">General</option>
<option value="web">Web Development</option>
<option value="mobile">Mobile App</option>
<option value="infrastructure">Infrastructure</option>
<option value="research">Research</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeModal()">Cancel</button>
<button class="btn-primary" onclick="submitNewProject()">Create</button>
</div>
</div>
<div id="attach-cli-modal" class="modal hidden">
<div class="modal-header">
<h2>Attach CLI Session</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<p class="modal-info">
Enter the session ID from your Claude Code CLI session to continue it in the web interface.
</p>
<div class="form-group">
<label>Session ID</label>
<input type="text" id="cli-session-id" placeholder="e.g., session-1234567890-abc123">
<small style="display: block; margin-top: 0.5rem; color: var(--text-secondary);">
Tip: When you start Claude Code in the terminal, it shows the session ID at the top.
</small>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeModal()">Cancel</button>
<button class="btn-primary" onclick="submitAttachCliSession()">Attach</button>
</div>
</div>
</div>
<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>
</body>
</html>

View File

@@ -0,0 +1,218 @@
/**
* Preview Manager Styles
*/
/* Preview Panel */
.preview-panel {
position: fixed;
bottom: 0;
right: 0;
left: 0;
height: 60vh;
background: #1e1e1e;
border-top: 2px solid #333;
display: flex;
flex-direction: column;
z-index: 1000;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3);
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
background: #252525;
border-bottom: 1px solid #333;
}
.preview-title {
display: flex;
align-items: center;
gap: 12px;
}
.preview-title h3 {
margin: 0;
font-size: 16px;
color: #e0e0e0;
}
.preview-url {
font-size: 12px;
color: #888;
background: #1a1a1a;
padding: 4px 8px;
border-radius: 4px;
font-family: monospace;
}
.preview-actions {
display: flex;
gap: 8px;
}
.preview-content {
flex: 1;
position: relative;
background: #fff;
}
#preview-iframe {
width: 100%;
height: 100%;
border: none;
}
.preview-status {
padding: 8px 20px;
background: #1a1a1a;
border-top: 1px solid #333;
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #888;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;
}
.status-running {
background: #51cf66;
}
.status-stopped {
background: #ff6b6b;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Preview Error */
.preview-error {
position: fixed;
bottom: 20px;
right: 20px;
background: #3a1e1e;
border: 1px solid #a83d3d;
border-radius: 8px;
padding: 16px 20px;
max-width: 400px;
z-index: 2000;
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.preview-error .error-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.preview-error .error-icon {
font-size: 24px;
}
.preview-error .error-header h4 {
margin: 0;
flex: 1;
color: #ff6b6b;
font-size: 16px;
}
.preview-error .btn-close {
background: #3a3a3a;
border: 1px solid #555;
color: #e0e0e0;
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
line-height: 1;
}
.preview-error .error-message {
color: #e0e0e0;
font-size: 14px;
line-height: 1.5;
}
/* Button variants */
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
}
.btn-sm {
padding: 4px 12px;
font-size: 13px;
}
.btn-secondary {
background: #3a3a3a;
color: #e0e0e0;
border: 1px solid #555;
}
.btn-secondary:hover {
background: #4a4a4a;
border-color: #666;
}
.btn-danger {
background: #a83d3d;
color: white;
}
.btn-danger:hover {
background: #c94e4e;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.preview-panel {
height: 50vh;
}
.preview-header {
flex-direction: column;
gap: 8px;
align-items: stretch;
}
.preview-actions {
justify-content: space-between;
}
.preview-title {
justify-content: space-between;
}
}

View File

@@ -0,0 +1,230 @@
/**
* Live Preview Manager
* Manages application preview in an iframe
*/
class PreviewManager {
constructor() {
this.previewUrl = null;
this.previewServer = null;
this.previewPort = null;
this.isPreviewRunning = false;
}
/**
* Start preview server
*/
async startPreview(sessionId, workingDir) {
console.log('Starting preview for session:', sessionId);
try {
const res = await fetch(`/claude/api/claude/sessions/${sessionId}/preview/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir })
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to start preview');
}
this.previewUrl = data.url;
this.previewPort = data.port;
this.previewServer = data.processId;
this.isPreviewRunning = true;
console.log('Preview started:', this.previewUrl);
// Show preview panel
this.showPreviewPanel(this.previewUrl);
return data;
} catch (error) {
console.error('Error starting preview:', error);
this.showError(error.message);
throw error;
}
}
/**
* Stop preview server
*/
async stopPreview(sessionId) {
console.log('Stopping preview for session:', sessionId);
try {
const res = await fetch(`/claude/api/claude/sessions/${sessionId}/preview/stop`, {
method: 'POST'
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to stop preview');
}
this.isPreviewRunning = false;
this.previewUrl = null;
this.previewServer = null;
// Hide preview panel
this.hidePreviewPanel();
console.log('Preview stopped');
return data;
} catch (error) {
console.error('Error stopping preview:', error);
throw error;
}
}
/**
* Refresh preview
*/
refreshPreview() {
if (!this.previewUrl || !this.isPreviewRunning) {
console.warn('No preview to refresh');
return;
}
const iframe = document.getElementById('preview-iframe');
if (iframe) {
iframe.src = iframe.src; // Reload iframe
console.log('Preview refreshed');
}
}
/**
* Show preview panel
*/
showPreviewPanel(url) {
// Remove existing panel
const existing = document.querySelector('.preview-panel');
if (existing) {
existing.remove();
}
// Create preview panel
const previewPanel = document.createElement('div');
previewPanel.className = 'preview-panel';
previewPanel.innerHTML = `
<div class="preview-header">
<div class="preview-title">
<h3>Live Preview</h3>
<span class="preview-url">${url}</span>
</div>
<div class="preview-actions">
<button class="btn btn-secondary btn-sm" onclick="previewManager.refreshPreview()" title="Refresh">
🔄 Refresh
</button>
<button class="btn btn-secondary btn-sm" onclick="previewManager.openInNewTab()" title="Open in new tab">
🔗 Open
</button>
<button class="btn btn-danger btn-sm" onclick="stopCurrentPreview()" title="Stop preview">
✕ Stop
</button>
</div>
</div>
<div class="preview-content">
<iframe id="preview-iframe" src="${url}" sandbox="allow-scripts allow-same-origin allow-forms allow-popups"></iframe>
</div>
<div class="preview-status">
<span class="status-dot status-running"></span>
<span>Preview running on ${this.previewPort || 'local port'}</span>
</div>
`;
// Add to page - find best place to insert
const chatView = document.getElementById('chat-view');
if (chatView) {
chatView.appendChild(previewPanel);
}
previewPanel.style.display = 'block';
}
/**
* Hide preview panel
*/
hidePreviewPanel() {
const panel = document.querySelector('.preview-panel');
if (panel) {
panel.remove();
}
}
/**
* Open preview in new tab
*/
openInNewTab() {
if (this.previewUrl) {
window.open(this.previewUrl, '_blank');
}
}
/**
* Show error message
*/
showError(message) {
// Remove existing error
const existing = document.querySelector('.preview-error');
if (existing) {
existing.remove();
}
const error = document.createElement('div');
error.className = 'preview-error';
error.innerHTML = `
<div class="error-header">
<span class="error-icon">⚠️</span>
<h4>Preview Error</h4>
<button class="btn-close" onclick="this.closest('.preview-error').remove()">✕</button>
</div>
<div class="error-message">${message}</div>
`;
const chatView = document.getElementById('chat-view');
if (chatView) {
chatView.appendChild(error);
}
error.style.display = 'block';
// Auto-hide after 5 seconds
setTimeout(() => {
error.remove();
}, 5000);
}
/**
* Update preview status
*/
updateStatus(status, message) {
const statusElement = document.querySelector('.preview-status');
if (statusElement) {
const dotClass = status === 'running' ? 'status-running' : 'status-stopped';
statusElement.innerHTML = `
<span class="status-dot ${dotClass}"></span>
<span>${message}</span>
`;
}
}
}
// Global instance
const previewManager = new PreviewManager();
// Global function to stop preview
window.stopCurrentPreview = async function() {
if (window.chatSessionId) {
await previewManager.stopPreview(window.chatSessionId);
}
};
// Export for use in other files
if (typeof module !== 'undefined' && module.exports) {
module.exports = PreviewManager;
}

View File

@@ -0,0 +1,475 @@
/**
* Sessions Landing Page Styles
*/
body.sessions-page {
background: #0d0d0d;
min-height: 100vh;
}
/* === Hero Section === */
.hero-section {
min-height: 50vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px 60px;
text-align: center;
}
.hero-title {
font-size: 56px;
font-weight: 700;
margin: 0 0 16px 0;
background: linear-gradient(135deg, #4a9eff 0%, #a78bfa 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-subtitle {
font-size: 20px;
color: #888;
margin: 0 0 48px 0;
}
.project-input {
width: 100%;
max-width: 600px;
padding: 18px 24px;
font-size: 18px;
background: #1a1a1a;
border: 2px solid #333;
border-radius: 12px;
color: #e0e0e0;
outline: none;
transition: all 0.3s ease;
text-align: center;
}
.project-input:focus {
border-color: #4a9eff;
box-shadow: 0 0 0 4px rgba(74, 158, 255, 0.15);
}
.input-status {
height: 20px;
margin-top: 12px;
font-size: 14px;
color: #ff6b6b;
}
/* === Projects Section === */
.projects-section {
padding: 40px 20px 80px;
border-top: 1px solid #222;
}
.projects-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
max-width: 1200px;
margin-left: auto;
margin-right: auto;
}
.projects-title {
font-size: 24px;
font-weight: 600;
color: #e0e0e0;
margin: 0;
}
.btn-refresh {
padding: 8px 16px;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
color: #e0e0e0;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-refresh:hover {
background: #252525;
border-color: #4a9eff;
}
.projects-empty {
max-width: 1200px;
margin: 0 auto;
text-align: center;
padding: 80px 40px;
color: #888;
font-size: 16px;
}
/* === Projects Table === */
.projects-table-wrapper {
max-width: 1200px;
margin: 0 auto;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
overflow: hidden;
}
.projects-table {
width: 100%;
border-collapse: collapse;
}
.projects-table thead {
background: #0d0d0d;
border-bottom: 1px solid #333;
}
.projects-table th {
padding: 16px 20px;
text-align: left;
font-size: 13px;
font-weight: 600;
color: #888;
text-transform: uppercase;
}
.projects-table tbody tr {
border-bottom: 1px solid #252525;
transition: background 0.2s ease;
cursor: pointer;
}
.projects-table tbody tr:hover {
background: #252525;
}
.projects-table tbody tr:last-child {
border-bottom: none;
}
.projects-table td {
padding: 16px 20px;
vertical-align: middle;
}
/* Column Widths */
.col-name { width: 40%; }
.col-activity { width: 25%; }
.col-status { width: 15%; }
.col-actions { width: 20%; text-align: right; }
/* Project Name */
.project-name {
font-size: 16px;
font-weight: 600;
color: #e0e0e0;
margin: 0;
}
.project-name-input {
background: #0d0d0d;
border: 2px solid #4a9eff;
border-radius: 6px;
padding: 8px 12px;
color: #e0e0e0;
font-size: 16px;
font-weight: 600;
font-family: inherit;
width: 100%;
max-width: 300px;
outline: none;
transition: all 0.2s ease;
}
.project-name-input:focus {
box-shadow: 0 0 0 3px rgba(74, 158, 255, 0.15);
}
/* Last Activity */
.last-activity {
font-size: 14px;
color: #888;
}
/* Status Badges */
.status-badge {
display: inline-block;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
}
.status-badge.active { color: #51cf66; background: rgba(81, 207, 102, 0.15); }
.status-badge.done { color: #888; background: rgba(136, 136, 136, 0.15); }
.status-badge.archived { color: #ffa94d; background: rgba(255, 169, 77, 0.15); }
/* Buttons */
.btn-continue {
padding: 8px 20px;
background: linear-gradient(135deg, #4a9eff 0%, #a78bfa 100%);
border: none;
border-radius: 8px;
color: white;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-continue:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(74, 158, 255, 0.4);
}
.btn-menu {
background: #2a2a2a;
border: 1px solid #444;
color: #e0e0e0;
width: 36px;
height: 36px;
border-radius: 8px;
font-size: 20px;
cursor: pointer;
margin-left: 8px;
transition: all 0.2s ease;
}
.btn-menu:hover {
background: #3a3a3a;
border-color: #666;
}
/* Dropdown Menu */
.dropdown-menu {
position: absolute;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
z-index: 1000;
min-width: 180px;
}
.dropdown-menu-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 12px 16px;
background: none;
border: none;
color: #e0e0e0;
font-size: 14px;
cursor: pointer;
transition: background 0.2s ease;
}
.dropdown-menu-item:hover { background: #3a3a3a; }
.dropdown-menu-item:first-child { border-radius: 8px 8px 0 0; }
.dropdown-menu-item:last-child { border-radius: 0 0 8px 8px; }
.dropdown-menu-item.danger { color: #ff6b6b; }
/* Loading State */
.loading {
text-align: center;
padding: 40px;
color: #888;
font-size: 16px;
}
/* Responsive Design */
@media (max-width: 768px) {
.hero-title { font-size: 40px; }
.hero-subtitle { font-size: 16px; }
.project-input { font-size: 16px; padding: 14px 20px; }
.projects-table th,
.projects-table td { padding: 12px 16px; }
.col-actions { display: none; }
}
@media (max-width: 480px) {
.hero-section { padding: 60px 16px 40px; }
.hero-title { font-size: 32px; }
.hero-subtitle { font-size: 14px; }
.project-input { font-size: 14px; padding: 12px 16px; }
.projects-section { padding: 24px 16px 60px; }
.projects-header { flex-direction: column; align-items: flex-start; gap: 12px; }
.projects-table thead { display: none; }
.projects-table tbody { display: flex; flex-direction: column; gap: 12px; }
.projects-table tr {
display: flex;
flex-direction: column;
border: 1px solid #333;
border-radius: 8px;
padding: 16px;
gap: 8px;
}
.projects-table td {
padding: 4px 0;
display: flex;
justify-content: space-between;
align-items: center;
border: none;
}
.projects-table td::before {
content: attr(data-label);
font-size: 12px;
color: #888;
font-weight: 600;
}
.col-actions { display: flex; justify-content: flex-end; gap: 8px; }
}
/**
* Loading Overlay
*/
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 10000;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.loading-overlay.hidden {
display: none;
}
.loading-overlay.visible {
opacity: 1;
pointer-events: auto;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 4px solid #333;
border-top-color: #4a9eff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
margin-top: 20px;
color: #e0e0e0;
font-size: 16px;
font-weight: 500;
}
/**
* Toast Notifications
*/
.toast-notification {
position: fixed;
top: 20px;
right: 20px;
min-width: 300px;
max-width: 500px;
padding: 16px 20px;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
gap: 12px;
z-index: 10001;
opacity: 0;
transform: translateX(400px);
transition: all 0.3s ease;
}
.toast-notification.visible {
opacity: 1;
transform: translateX(0);
}
.toast-icon {
font-size: 20px;
font-weight: bold;
flex-shrink: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.toast-message {
flex: 1;
color: #e0e0e0;
font-size: 14px;
line-height: 1.4;
}
/* Toast Types */
.toast-success {
border-color: #51cf66;
}
.toast-success .toast-icon {
color: #51cf66;
background: rgba(81, 207, 102, 0.1);
}
.toast-error {
border-color: #ff6b6b;
}
.toast-error .toast-icon {
color: #ff6b6b;
background: rgba(255, 107, 107, 0.1);
}
.toast-info {
border-color: #4a9eff;
}
.toast-info .toast-icon {
color: #4a9eff;
background: rgba(74, 158, 255, 0.1);
}
.toast-warning {
border-color: #ffa94d;
}
.toast-warning .toast-icon {
color: #ffa94d;
background: rgba(255, 169, 77, 0.1);
}
/* Responsive Toast */
@media (max-width: 768px) {
.toast-notification {
top: 10px;
right: 10px;
left: 10px;
min-width: auto;
max-width: none;
}
}

View File

@@ -0,0 +1,608 @@
/**
* Sessions Landing Page JavaScript
*/
// Load sessions on page load
document.addEventListener('DOMContentLoaded', () => {
checkAuth();
initializeProjectInput();
loadSessions();
});
// Check authentication
async function checkAuth() {
try {
const res = await fetch('/claude/api/auth/status');
if (!res.ok) {
throw new Error('Request failed');
}
const data = await res.json();
if (!data.authenticated) {
// Redirect to login if not authenticated
window.location.href = '/claude/login.html';
}
} catch (error) {
console.error('Auth check failed:', error);
}
}
/**
* Initialize project input field in hero section
*/
function initializeProjectInput() {
const input = document.getElementById('project-input');
const status = document.getElementById('input-status');
if (!input) return;
// Auto-focus on page load
input.focus();
input.addEventListener('input', () => {
const projectName = input.value.trim();
const hasInvalidChars = !validateProjectName(projectName);
if (hasInvalidChars && projectName.length > 0) {
status.textContent = 'Invalid characters';
status.classList.add('error');
} else {
status.textContent = '';
status.classList.remove('error');
}
});
input.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
const projectName = input.value.trim();
if (projectName && validateProjectName(projectName)) {
createProject(projectName);
}
}
});
}
/**
* Create a new project and navigate to IDE
*/
async function createProject(projectName) {
try {
showLoadingOverlay('Creating project...');
const res = await fetch('/claude/api/claude/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
metadata: {
type: 'chat',
source: 'web-ide',
project: projectName
}
})
});
if (!res.ok) throw new Error('Request failed');
const data = await res.json();
if (data.success) {
// Minimum display time for smooth UX
await new Promise(resolve => setTimeout(resolve, 300));
window.location.href = `/claude/ide?session=${data.session.id}`;
}
} catch (error) {
console.error('Error creating project:', error);
hideLoadingOverlay();
showToast('Failed to create project', 'error');
}
}
/**
* Load all sessions and render in table
*/
async function loadSessions() {
const tbody = document.getElementById('projects-tbody');
const emptyState = document.getElementById('projects-empty');
const table = document.getElementById('projects-table');
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="4" style="text-align: center; padding: 40px;">Loading...</td></tr>';
try {
const res = await fetch('/claude/api/claude/sessions');
if (!res.ok) throw new Error('Request failed');
const data = await res.json();
const allSessions = [
...(data.active || []).map(s => ({...s, type: 'active'})),
...(data.historical || []).map(s => ({...s, type: 'historical'}))
];
renderProjectsTable(allSessions);
} catch (error) {
console.error('Error loading sessions:', error);
tbody.innerHTML = '<tr><td colspan="4" style="text-align: center; color: #ff6b6b;">Failed to load</td></tr>';
}
}
/**
* Render projects table with session data
*/
function renderProjectsTable(sessions) {
const tbody = document.getElementById('projects-tbody');
const emptyState = document.getElementById('projects-empty');
const table = document.getElementById('projects-table');
if (!tbody) return;
// Sort by last activity (newest first)
sessions.sort((a, b) => {
const dateA = new Date(a.lastActivity || a.createdAt || a.created_at);
const dateB = new Date(b.lastActivity || b.createdAt || b.created_at);
return dateB - dateA;
});
tbody.innerHTML = '';
if (sessions.length === 0) {
if (table) table.style.display = 'none';
if (emptyState) emptyState.style.display = 'block';
return;
}
if (table) table.style.display = 'table';
if (emptyState) emptyState.style.display = 'none';
sessions.forEach(session => {
const row = createProjectRow(session);
tbody.appendChild(row);
});
}
/**
* Create a single project row for the table
*/
function createProjectRow(session) {
const tr = document.createElement('tr');
tr.dataset.sessionId = session.id;
const projectName = getProjectName(session);
const relativeTime = getRelativeTime(session);
const status = getStatus(session);
const statusClass = getStatusClass(session);
tr.innerHTML = `
<td data-label="Project">
<h3 class="project-name">${escapeHtml(projectName)}</h3>
</td>
<td data-label="Last Activity">
<span class="last-activity">${relativeTime}</span>
</td>
<td data-label="Status">
<span class="status-badge ${statusClass}">${status}</span>
</td>
<td data-label="Actions">
<button class="btn-continue">Continue</button>
<button class="btn-menu" aria-label="Menu">⋮</button>
</td>
`;
tr.addEventListener('click', (e) => {
if (!e.target.closest('.btn-menu')) {
continueToSession(session.id);
}
});
tr.querySelector('.btn-continue').addEventListener('click', (e) => {
e.stopPropagation();
continueToSession(session.id);
});
tr.querySelector('.btn-menu').addEventListener('click', (e) => {
e.stopPropagation();
showProjectMenu(e, session);
});
return tr;
}
/**
* Extract project name from session metadata
*/
function getProjectName(session) {
return session.metadata?.project ||
session.metadata?.projectName ||
session.workingDir?.split('/').pop() ||
'Session ' + session.id.substring(0, 8);
}
/**
* Get relative time string for session
*/
function getRelativeTime(session) {
const date = new Date(session.lastActivity || session.createdAt || session.created_at);
const now = new Date();
const diffMins = Math.floor((now - date) / 60000);
const diffHours = Math.floor((now - date) / 3600000);
const diffDays = Math.floor((now - date) / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
/**
* Get status text for session
*/
function getStatus(session) {
if (session.status === 'running') return 'Active';
return 'Done';
}
/**
* Get status CSS class for session
*/
function getStatusClass(session) {
if (session.status === 'running') return 'active';
return 'done';
}
/**
* Navigate to IDE with session
*/
async function continueToSession(sessionId) {
showLoadingOverlay('Opening workspace...');
await new Promise(resolve => setTimeout(resolve, 300));
window.location.href = `/claude/ide?session=${sessionId}`;
}
/**
* Show project menu dropdown
*/
function showProjectMenu(event, session) {
closeProjectMenu();
const menu = document.createElement('div');
menu.className = 'dropdown-menu';
menu.id = 'project-menu';
menu.innerHTML = `
<button class="dropdown-menu-item" data-action="rename">✏️ Rename</button>
<button class="dropdown-menu-item" data-action="duplicate">📋 Duplicate</button>
<button class="dropdown-menu-item danger" data-action="delete">🗑️ Delete</button>
`;
const rect = event.target.getBoundingClientRect();
menu.style.position = 'fixed';
menu.style.top = `${rect.bottom + 8}px`;
menu.style.right = `${window.innerWidth - rect.right}px`;
document.body.appendChild(menu);
menu.querySelector('[data-action="rename"]').addEventListener('click', () => {
closeProjectMenu();
renameProject(session);
});
menu.querySelector('[data-action="duplicate"]').addEventListener('click', async () => {
closeProjectMenu();
await duplicateProject(session);
loadSessions();
});
menu.querySelector('[data-action="delete"]').addEventListener('click', async () => {
closeProjectMenu();
await deleteProject(session);
loadSessions();
});
setTimeout(() => {
document.addEventListener('click', closeProjectMenu, { once: true });
}, 10);
}
/**
* Close project menu dropdown
*/
function closeProjectMenu() {
document.getElementById('project_menu')?.remove();
}
/**
* Rename project (inline edit)
*/
function renameProject(session) {
const row = document.querySelector(`tr[data-session-id="${session.id}"]`);
if (!row) return;
const nameCell = row.querySelector('.project-name');
if (!nameCell) return;
const currentName = nameCell.textContent;
// Only block truly historical sessions (loaded from disk, not in memory)
// Active and terminated sessions in memory can be renamed
if (session.type === 'historical') {
showToast('Cannot rename historical sessions', 'warning');
return;
}
// Create input element
const input = document.createElement('input');
input.type = 'text';
input.className = 'project-name-input';
input.value = currentName;
input.maxLength = 50;
// Replace text with input
nameCell.innerHTML = '';
nameCell.appendChild(input);
input.focus();
input.select();
// Handle save
const saveRename = async () => {
const newName = input.value.trim();
// Validation
if (!newName) {
nameCell.textContent = currentName;
showToast('Project name cannot be empty', 'error');
return;
}
if (!validateProjectName(newName)) {
nameCell.textContent = currentName;
showToast('Project name contains invalid characters', 'error');
return;
}
if (newName === currentName) {
nameCell.textContent = currentName;
return;
}
// Show saving state
input.disabled = true;
input.style.opacity = '0.6';
try {
const res = await fetch(`/claude/api/claude/sessions/${session.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
metadata: {
...session.metadata,
project: newName
}
})
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Failed to rename');
}
const data = await res.json();
if (data.success) {
nameCell.textContent = newName;
showToast('Project renamed successfully', 'success');
} else {
throw new Error(data.error || 'Failed to rename');
}
} catch (error) {
console.error('Error renaming project:', error);
nameCell.textContent = currentName;
showToast(error.message || 'Failed to rename project', 'error');
}
};
// Handle cancel
const cancelRename = () => {
nameCell.textContent = currentName;
};
// Event listeners
input.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
input.blur(); // Trigger save on blur
} else if (e.key === 'Escape') {
cancelRename();
}
});
input.addEventListener('blur', () => {
// Small delay to allow Enter key to process first
setTimeout(() => {
if (document.activeElement !== input) {
saveRename();
}
}, 100);
});
}
/**
* Duplicate project/session
*/
async function duplicateProject(session) {
try {
showLoadingOverlay('Duplicating project...');
const res = await fetch(`/claude/api/claude/sessions/${session.id}/duplicate`, {
method: 'POST'
});
if (!res.ok) throw new Error('Request failed');
const data = await res.json();
if (data.success) {
hideLoadingOverlay();
showToast('Project duplicated successfully', 'success');
} else {
throw new Error(data.error || 'Failed to duplicate');
}
} catch (error) {
console.error('Error duplicating project:', error);
hideLoadingOverlay();
showToast('Failed to duplicate project', 'error');
}
}
/**
* Delete project/session with confirmation
*/
async function deleteProject(session) {
const projectName = getProjectName(session);
if (!confirm(`Are you sure you want to delete "${projectName}"? This action cannot be undone.`)) {
return;
}
try {
showLoadingOverlay('Deleting project...');
const res = await fetch(`/claude/api/claude/sessions/${session.id}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error('Request failed');
const data = await res.json();
if (data.success) {
hideLoadingOverlay();
showToast('Project deleted successfully', 'success');
} else {
throw new Error(data.error || 'Failed to delete');
}
} catch (error) {
console.error('Error deleting project:', error);
hideLoadingOverlay();
showToast('Failed to delete project', 'error');
}
}
// Refresh sessions
function refreshSessions() {
loadSessions();
}
/**
* Validate project name for invalid characters
* @param {string} name - The project name to validate
* @returns {boolean} - True if valid, false if contains invalid characters
*/
function validateProjectName(name) {
const invalidChars = /[\/\\<>:"|?*]/;
return !invalidChars.test(name);
}
/**
* Show loading overlay
* @param {string} message - Optional custom message (default: "Loading...")
*/
function showLoadingOverlay(message = 'Loading...') {
let overlay = document.getElementById('loading-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'loading-overlay';
overlay.className = 'loading-overlay';
overlay.innerHTML = `
<div class="loading-spinner"></div>
<p class="loading-text">${escapeHtml(message)}</p>
`;
document.body.appendChild(overlay);
} else {
// Update message if provided
const textElement = overlay.querySelector('.loading-text');
if (textElement) {
textElement.textContent = message;
}
}
overlay.classList.remove('hidden');
setTimeout(() => {
overlay.classList.add('visible');
}, 10);
}
/**
* Hide loading overlay
*/
function hideLoadingOverlay() {
const overlay = document.getElementById('loading-overlay');
if (overlay) {
overlay.classList.remove('visible');
setTimeout(() => {
overlay.classList.add('hidden');
}, 300);
}
}
/**
* Show toast notification
* @param {string} message - The message to display
* @param {string} type - The type of toast: 'success', 'error', 'info'
* @param {number} duration - Duration in milliseconds (default: 3000)
*/
function showToast(message, type = 'info', duration = 3000) {
// Remove existing toasts
const existingToasts = document.querySelectorAll('.toast-notification');
existingToasts.forEach(toast => toast.remove());
// Create toast element
const toast = document.createElement('div');
toast.className = `toast-notification toast-${type}`;
toast.innerHTML = `
<span class="toast-icon">${getToastIcon(type)}</span>
<span class="toast-message">${escapeHtml(message)}</span>
`;
document.body.appendChild(toast);
// Trigger animation
setTimeout(() => {
toast.classList.add('visible');
}, 10);
// Auto remove after duration
setTimeout(() => {
toast.classList.remove('visible');
setTimeout(() => {
toast.remove();
}, 300);
}, duration);
}
/**
* Get toast icon based on type
*/
function getToastIcon(type) {
const icons = {
success: '✓',
error: '✕',
info: '',
warning: '⚠'
};
return icons[type] || icons.info;
}
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}

View File

@@ -0,0 +1,395 @@
/**
* Tag Renderer Styles
*/
/* Operations Panel */
.operations-panel {
background: #1e1e1e;
border: 1px solid #333;
border-radius: 8px;
margin: 20px 0;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.operations-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: #252525;
border-bottom: 1px solid #333;
}
.operations-header h3 {
margin: 0;
font-size: 16px;
color: #e0e0e0;
}
.operations-actions {
display: flex;
gap: 10px;
}
.operations-list {
max-height: 500px;
overflow-y: auto;
}
/* Operation Item */
.operation-item {
display: flex;
align-items: center;
padding: 12px 20px;
border-bottom: 1px solid #333;
gap: 12px;
transition: background 0.2s;
}
.operation-item:hover {
background: #2a2a2a;
}
.operation-item:last-child {
border-bottom: none;
}
.operation-icon {
font-size: 24px;
width: 40px;
text-align: center;
}
.operation-details {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
}
.operation-description {
flex: 1;
color: #e0e0e0;
font-size: 14px;
}
.btn-view-code {
background: #3a3a3a;
border: 1px solid #555;
color: #e0e0e0;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.btn-view-code:hover {
background: #4a4a4a;
border-color: #666;
}
.operation-actions {
display: flex;
gap: 8px;
}
.operation-status {
min-width: 80px;
text-align: right;
}
.operation-status span {
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
font-weight: 500;
}
/* Operation Type Colors */
.op-write {
border-left: 3px solid #4a9eff;
}
.op-rename {
border-left: 3px solid #ffa94d;
}
.op-delete {
border-left: 3px solid #ff6b6b;
}
.op-install {
border-left: 3px solid #51cf66;
}
.op-command {
border-left: 3px solid #cc5de8;
}
/* Status Colors */
.status-pending {
color: #ffd43b;
background: rgba(255, 212, 59, 0.1);
}
.status-approved {
color: #51cf66;
background: rgba(81, 207, 102, 0.1);
}
.status-rejected {
color: #ff6b6b;
background: rgba(255, 107, 107, 0.1);
}
.status-executing {
color: #4a9eff;
background: rgba(74, 158, 255, 0.1);
}
/* Buttons */
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-approve {
background: #2b7a4b;
color: white;
}
.btn-approve:hover:not(:disabled) {
background: #349858;
}
.btn-reject {
background: #a83d3d;
color: white;
}
.btn-reject:hover:not(:disabled) {
background: #c94e4e;
}
.btn-approve-all {
background: #2b7a4b;
color: white;
padding: 6px 12px;
font-size: 13px;
}
.btn-approve-all:hover {
background: #349858;
}
.btn-reject-all {
background: #a83d3d;
color: white;
padding: 6px 12px;
font-size: 13px;
}
.btn-reject-all:hover {
background: #c94e4e;
}
/* Progress Indicator */
.operation-progress {
background: #1e1e1e;
border: 1px solid #333;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
display: flex;
align-items: center;
gap: 16px;
}
.progress-spinner {
width: 24px;
height: 24px;
border: 3px solid #333;
border-top-color: #4a9eff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.progress-message {
color: #e0e0e0;
font-size: 14px;
}
/* Completion Message */
.operation-completion {
background: #1e3a2e;
border: 1px solid #2b7a4b;
border-radius: 8px;
padding: 16px 20px;
margin: 20px 0;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.completion-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.completion-icon {
font-size: 24px;
}
.completion-header h4 {
margin: 0;
color: #51cf66;
font-size: 16px;
}
.completion-summary {
display: flex;
gap: 16px;
font-size: 14px;
}
.completion-summary .success {
color: #51cf66;
}
.completion-summary .error {
color: #ff6b6b;
}
/* Error Message */
.operation-error {
background: #3a1e1e;
border: 1px solid #a83d3d;
border-radius: 8px;
padding: 16px 20px;
margin: 20px 0;
}
.error-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.error-icon {
font-size: 24px;
}
.error-header h4 {
margin: 0;
color: #ff6b6b;
font-size: 16px;
}
.error-message {
color: #e0e0e0;
font-size: 14px;
line-height: 1.5;
}
/* Code Modal */
.code-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-content {
background: #1e1e1e;
border: 1px solid #333;
border-radius: 8px;
max-width: 80vw;
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #333;
}
.modal-header h3 {
margin: 0;
color: #e0e0e0;
font-size: 16px;
}
.btn-close {
background: #3a3a3a;
border: 1px solid #555;
color: #e0e0e0;
width: 32px;
height: 32px;
border-radius: 4px;
cursor: pointer;
font-size: 18px;
line-height: 1;
}
.btn-close:hover {
background: #4a4a4a;
}
.modal-content pre {
margin: 0;
padding: 20px;
overflow: auto;
flex: 1;
}
.modal-content code {
font-family: 'Fira Code', 'Courier New', monospace;
font-size: 13px;
line-height: 1.5;
color: #e0e0e0;
}

View File

@@ -0,0 +1,543 @@
/**
* Tag Renderer - Display and manage dyad-style operations in UI
*/
class TagRenderer {
constructor() {
this.pendingOperations = null;
this.isExecuting = false;
}
/**
* Parse tags from text response
*/
parseTags(response) {
const tags = {
writes: this.extractWriteTags(response),
renames: this.extractRenameTags(response),
deletes: this.extractDeleteTags(response),
dependencies: this.extractDependencyTags(response),
commands: this.extractCommandTags(response)
};
return tags;
}
/**
* Extract dyad-write tags
*/
extractWriteTags(response) {
const regex = /<dyad-write\s+path="([^"]+)">([\s\S]*?)<\/dyad-write>/g;
const tags = [];
let match;
while ((match = regex.exec(response)) !== null) {
tags.push({
type: 'write',
path: match[1],
content: match[2].trim()
});
}
return tags;
}
/**
* Extract dyad-rename tags
*/
extractRenameTags(response) {
const regex = /<dyad-rename\s+from="([^"]+)"\s+to="([^"]+)">/g;
const tags = [];
let match;
while ((match = regex.exec(response)) !== null) {
tags.push({
type: 'rename',
from: match[1],
to: match[2]
});
}
return tags;
}
/**
* Extract dyad-delete tags
*/
extractDeleteTags(response) {
const regex = /<dyad-delete\s+path="([^"]+)">/g;
const tags = [];
let match;
while ((match = regex.exec(response)) !== null) {
tags.push({
type: 'delete',
path: match[1]
});
}
return tags;
}
/**
* Extract dyad-add-dependency tags
*/
extractDependencyTags(response) {
const regex = /<dyad-add-dependency\s+packages="([^"]+)">/g;
const tags = [];
let match;
while ((match = regex.exec(response)) !== null) {
tags.push({
type: 'install',
packages: match[1].split(' ').filter(p => p.trim())
});
}
return tags;
}
/**
* Extract dyad-command tags
*/
extractCommandTags(response) {
const regex = /<dyad-command\s+type="([^"]+)">/g;
const tags = [];
let match;
while ((match = regex.exec(response)) !== null) {
tags.push({
type: 'command',
command: match[1]
});
}
return tags;
}
/**
* Generate operation summary for display
*/
generateOperationsSummary(tags) {
const operations = [];
tags.deletes.forEach(tag => {
operations.push({
type: 'delete',
description: `Delete ${tag.path}`,
tag,
status: 'pending'
});
});
tags.renames.forEach(tag => {
operations.push({
type: 'rename',
description: `Rename ${tag.from}${tag.to}`,
tag,
status: 'pending'
});
});
tags.writes.forEach(tag => {
operations.push({
type: 'write',
description: `Create/update ${tag.path}`,
tag,
status: 'pending'
});
});
tags.dependencies.forEach(tag => {
operations.push({
type: 'install',
description: `Install packages: ${tag.packages.join(', ')}`,
tag,
status: 'pending'
});
});
tags.commands.forEach(tag => {
operations.push({
type: 'command',
description: `Execute command: ${tag.command}`,
tag,
status: 'pending'
});
});
return operations;
}
/**
* Strip tags from response for clean display
*/
stripTags(response) {
let stripped = response;
stripped = stripped.replace(/<dyad-write\s+path="[^"]+">[\s\S]*?<\/dyad-write>/g, '[File: code]');
stripped = stripped.replace(/<dyad-rename\s+from="[^"]+"\s+to="[^"]+">/g, '[Rename]');
stripped = stripped.replace(/<dyad-delete\s+path="[^"]+">/g, '[Delete]');
stripped = stripped.replace(/<dyad-add-dependency\s+packages="[^"]+">/g, '[Install]');
stripped = stripped.replace(/<dyad-command\s+type="[^"]+">/g, '[Command]');
return stripped;
}
/**
* Render operations panel HTML
*/
renderOperationsPanel(operations) {
const panel = document.createElement('div');
panel.className = 'operations-panel';
panel.innerHTML = `
<div class="operations-header">
<h3>Pending Operations (${operations.length})</h3>
<div class="operations-actions">
<button class="btn btn-approve-all" onclick="approveAllOperations()">
✓ Approve All
</button>
<button class="btn btn-reject-all" onclick="rejectAllOperations()">
✗ Reject All
</button>
</div>
</div>
<div class="operations-list">
${operations.map((op, index) => this.renderOperationItem(op, index)).join('')}
</div>
`;
return panel;
}
/**
* Render single operation item
*/
renderOperationItem(operation, index) {
const icon = this.getOperationIcon(operation.type);
const colorClass = this.getOperationColorClass(operation.type);
return `
<div class="operation-item ${colorClass}" data-index="${index}">
<div class="operation-icon">${icon}</div>
<div class="operation-details">
<div class="operation-description">${operation.description}</div>
${operation.tag.content ? `<button class="btn-view-code" onclick="viewCode(${index})">View Code</button>` : ''}
</div>
<div class="operation-actions">
<button class="btn btn-approve" onclick="approveOperation(${index})" title="Approve">✓</button>
<button class="btn btn-reject" onclick="rejectOperation(${index})" title="Reject">✗</button>
</div>
<div class="operation-status">
<span class="status-pending">Pending</span>
</div>
</div>
`;
}
/**
* Get icon for operation type
*/
getOperationIcon(type) {
const icons = {
write: '📄',
rename: '✏️',
delete: '🗑️',
install: '📦',
command: '⚡'
};
return icons[type] || '⚙️';
}
/**
* Get color class for operation type
*/
getOperationColorClass(type) {
const classes = {
write: 'op-write',
rename: 'op-rename',
delete: 'op-delete',
install: 'op-install',
command: 'op-command'
};
return classes[type] || '';
}
/**
* Show operations panel
*/
showOperationsPanel(operations, response) {
this.pendingOperations = operations;
window.currentOperationsResponse = response;
// Remove existing panel
const existing = document.querySelector('.operations-panel');
if (existing) {
existing.remove();
}
// Add new panel
const chatContainer = document.getElementById('chat-messages');
const panel = this.renderOperationsPanel(operations);
chatContainer.appendChild(panel);
// Make it visible
panel.style.display = 'block';
}
/**
* Update operation status
*/
updateOperationStatus(index, status) {
const operationItem = document.querySelector(`.operation-item[data-index="${index}"]`);
if (!operationItem) return;
const statusElement = operationItem.querySelector('.operation-status');
statusElement.innerHTML = `<span class="status-${status}">${status.charAt(0).toUpperCase() + status.slice(1)}</span>`;
if (status === 'approved' || status === 'rejected') {
// Disable buttons
const approveBtn = operationItem.querySelector('.btn-approve');
const rejectBtn = operationItem.querySelector('.btn-reject');
approveBtn.disabled = true;
rejectBtn.disabled = true;
}
}
/**
* Update all operations status
*/
updateAllOperationsStatus(status) {
if (!this.pendingOperations) return;
this.pendingOperations.forEach((_, index) => {
this.updateOperationStatus(index, status);
});
}
/**
* Hide operations panel
*/
hideOperationsPanel() {
const panel = document.querySelector('.operations-panel');
if (panel) {
panel.remove();
}
this.pendingOperations = null;
}
/**
* Show progress indicator
*/
showProgress(message) {
const chatContainer = document.getElementById('chat-messages');
const progress = document.createElement('div');
progress.className = 'operation-progress';
progress.innerHTML = `
<div class="progress-spinner"></div>
<div class="progress-message">${message}</div>
`;
chatContainer.appendChild(progress);
progress.style.display = 'block';
}
/**
* Update progress message
*/
updateProgress(message) {
const progressElement = document.querySelector('.operation-progress');
if (progressElement) {
const messageElement = progressElement.querySelector('.progress-message');
if (messageElement) {
messageElement.textContent = message;
}
}
}
/**
* Hide progress indicator
*/
hideProgress() {
const progressElement = document.querySelector('.operation-progress');
if (progressElement) {
progressElement.remove();
}
}
/**
* Show completion message
*/
showCompletion(results) {
const chatContainer = document.querySelector('.chat-container');
const completion = document.createElement('div');
completion.className = 'operation-completion';
const successCount = results.operations.filter(op => op.success).length;
const errorCount = results.errors.length;
completion.innerHTML = `
<div class="completion-header">
<span class="completion-icon">✓</span>
<h4>Operations Complete</h4>
</div>
<div class="completion-summary">
<span class="success">${successCount} succeeded</span>
${errorCount > 0 ? `<span class="error">${errorCount} failed</span>` : ''}
</div>
`;
chatContainer.appendChild(completion);
completion.style.display = 'block';
// Auto-hide after 3 seconds
setTimeout(() => {
completion.remove();
}, 3000);
}
/**
* Show error message
*/
showError(message) {
const chatContainer = document.querySelector('.chat-container');
const error = document.createElement('div');
error.className = 'operation-error';
error.innerHTML = `
<div class="error-header">
<span class="error-icon">⚠️</span>
<h4>Operation Failed</h4>
</div>
<div class="error-message">${message}</div>
`;
chatContainer.appendChild(error);
error.style.display = 'block';
}
}
// Global instance
const tagRenderer = new TagRenderer();
// Store current response for execution
window.currentOperationsResponse = null;
// Global functions for onclick handlers
window.approveOperation = async function(index) {
if (!tagRenderer.pendingOperations) return;
tagRenderer.updateOperationStatus(index, 'approved');
};
window.rejectOperation = function(index) {
if (!tagRenderer.pendingOperations) return;
tagRenderer.updateOperationStatus(index, 'rejected');
};
window.approveAllOperations = async function() {
if (!tagRenderer.pendingOperations) return;
tagRenderer.updateAllOperationsStatus('approved');
// Execute operations
await executeApprovedOperations();
};
window.rejectAllOperations = function() {
if (!tagRenderer.pendingOperations) return;
tagRenderer.hideOperationsPanel();
window.currentOperationsResponse = null;
};
window.viewCode = function(index) {
if (!tagRenderer.pendingOperations) return;
const operation = tagRenderer.pendingOperations[index];
if (operation.tag && operation.tag.content) {
// Show code in modal
const modal = document.createElement('div');
modal.className = 'code-modal';
modal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<h3>${operation.tag.path}</h3>
<button class="btn-close" onclick="this.closest('.code-modal').remove()">✕</button>
</div>
<pre><code>${escapeHtml(operation.tag.content)}</code></pre>
</div>
`;
document.body.appendChild(modal);
modal.style.display = 'block';
}
};
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Execute approved operations
*/
async function executeApprovedOperations() {
if (!window.currentOperationsResponse) {
console.error('No response to execute');
tagRenderer.showError('No operations to execute');
return;
}
// Use attachedSessionId (from chat-functions.js) instead of chatSessionId
const sessionId = window.attachedSessionId || window.chatSessionId;
if (!sessionId) {
console.error('No session ID - not attached to a session');
tagRenderer.showError('Not attached to a session. Please start or attach to a session first.');
return;
}
const response = window.currentOperationsResponse;
console.log('Executing operations for session:', sessionId);
try {
// Show progress
tagRenderer.showProgress('Executing operations...');
// Call execute API with credentials
const res = await fetch(`/claude/api/claude/sessions/${sessionId}/operations/execute`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ response })
});
const data = await res.json();
if (!res.ok) {
console.error('Execute API error:', data);
throw new Error(data.error || 'Failed to execute operations');
}
console.log('Operations executed successfully:', data.results);
// The WebSocket will handle showing completion
// when it receives the operations-executed event
} catch (error) {
console.error('Error executing operations:', error);
tagRenderer.hideProgress();
tagRenderer.showError(error.message);
}
}
// Export for use in other files
if (typeof module !== 'undefined' && module.exports) {
module.exports = TagRenderer;
}

View File

@@ -0,0 +1,658 @@
/**
* Terminal Panel Styles
*/
/* Terminal Layout */
.terminal-layout {
display: flex;
flex-direction: column;
height: calc(100vh - 60px);
background: #0d0d0d;
}
/* Terminal Header */
.terminal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
background: #1a1a1a;
border-bottom: 1px solid #333;
}
.terminal-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #e0e0e0;
}
/* Terminal Tabs */
.terminal-tabs {
display: flex;
gap: 2px;
padding: 8px 8px 0;
background: #0a0a0a;
border-bottom: 1px solid #333;
overflow-x: auto;
overflow-y: hidden;
min-height: 40px;
}
.terminal-tabs::-webkit-scrollbar {
height: 6px;
}
.terminal-tabs::-webkit-scrollbar-track {
background: #1a1a1a;
}
.terminal-tabs::-webkit-scrollbar-thumb {
background: #444;
border-radius: 3px;
}
.terminal-tabs::-webkit-scrollbar-thumb:hover {
background: #555;
}
.terminal-tab {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #1a1a1a;
border: 1px solid #333;
border-bottom: none;
border-radius: 6px 6px 0 0;
color: #888;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
user-select: none;
}
.terminal-tab:hover {
background: #252525;
color: #e0e0e0;
}
.terminal-tab.active {
background: #2a2a2a;
color: #e0e0e0;
border-color: #4a9eff;
border-bottom-color: #2a2a2a;
}
.tab-id {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 11px;
}
.tab-mode {
font-size: 14px;
}
.tab-close {
background: none;
border: none;
color: #888;
font-size: 18px;
cursor: pointer;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
transition: all 0.2s ease;
}
.tab-close:hover {
background: #ff6b6b;
color: white;
}
/* Terminals Container */
.terminals-container {
flex: 1;
position: relative;
overflow: hidden;
}
.terminal-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
text-align: center;
}
.terminal-placeholder h3 {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 600;
color: #888;
}
.terminal-placeholder p {
margin: 0;
font-size: 14px;
}
/* Terminal Container */
.terminal-container {
display: none;
flex-direction: column;
height: 100%;
background: #1a1a1a;
}
.terminal-container.active {
display: flex;
}
/* Terminal Toolbar */
.terminal-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: #252525;
border-bottom: 1px solid #333;
}
.terminal-info {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
}
.terminal-id {
font-family: 'Monaco', 'Menlo', monospace;
color: #888;
}
.terminal-path {
color: #4a9eff;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.terminal-mode {
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.terminal-mode[data-mode="session"] {
background: rgba(74, 158, 255, 0.15);
color: #4a9eff;
}
.terminal-mode[data-mode="shell"] {
background: rgba(81, 207, 102, 0.15);
color: #51cf66;
}
.terminal-mode[data-mode="mixed"] {
background: rgba(255, 193, 7, 0.15);
color: #ffc107;
}
.terminal-session {
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
color: #4a9eff;
margin-left: 8px;
}
.terminal-actions {
display: flex;
gap: 8px;
}
.btn-terminal-attach,
.btn-terminal-mode,
.btn-terminal-clear,
.btn-terminal-close {
padding: 6px 12px;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 4px;
color: #e0e0e0;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-terminal-attach:hover,
.btn-terminal-mode:hover,
.btn-terminal-clear:hover,
.btn-terminal-close:hover {
background: #3a3a3a;
border-color: #555;
}
.btn-terminal-close:hover {
background: #ff6b6b;
border-color: #ff6b6b;
color: white;
}
/* Terminal xterm.js container */
.terminal-xterm {
flex: 1;
padding: 8px;
overflow: hidden;
position: relative;
}
.terminal-xterm .xterm {
height: 100%;
}
.terminal-xterm .xterm:focus {
outline: 2px solid #4a9eff;
outline-offset: -2px;
}
.terminal-xterm .xterm .xterm-viewport {
overflow-y: auto;
}
/* Directory Picker Modal */
.directory-picker-modal {
max-width: 600px;
width: 90%;
}
.directory-picker-modal .modal-body {
max-height: 400px;
overflow-y: auto;
}
.recent-directories,
.custom-directory {
margin-bottom: 20px;
}
.recent-directories label,
.custom-directory label {
display: block;
font-size: 13px;
font-weight: 600;
color: #888;
margin-bottom: 8px;
text-transform: uppercase;
}
.directory-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 200px;
overflow-y: auto;
padding: 4px;
}
.directory-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
color: #e0e0e0;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
}
.directory-item:hover {
background: #252525;
border-color: #4a9eff;
}
.directory-item.selected {
background: #2a2a2a;
border-color: #4a9eff;
}
.dir-icon {
font-size: 16px;
}
.dir-path {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
}
.directory-input {
width: 100%;
padding: 10px 12px;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
color: #e0e0e0;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 13px;
outline: none;
}
.directory-input:focus {
border-color: #4a9eff;
}
/* Session Picker Modal */
.session-picker-modal {
max-width: 650px;
width: 90%;
}
.session-picker-modal .modal-body {
max-height: 450px;
overflow-y: auto;
}
.session-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 4px;
}
.session-list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
color: #e0e0e0;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
width: 100%;
}
.session-list-item:hover {
background: #252525;
border-color: #4a9eff;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(74, 158, 255, 0.15);
}
.session-list-item.selected {
background: #2a2a2a;
border-color: #4a9eff;
box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.3);
}
.session-list-item.create-new {
background: linear-gradient(135deg, rgba(74, 158, 255, 0.1) 0%, rgba(74, 158, 255, 0.05) 100%);
border-color: #4a9eff;
}
.session-list-item.create-new:hover {
background: linear-gradient(135deg, rgba(74, 158, 255, 0.15) 0%, rgba(74, 158, 255, 0.1) 100%);
border-color: #5aaeff;
}
.session-list-item.create-new.selected {
background: linear-gradient(135deg, rgba(74, 158, 255, 0.2) 0%, rgba(74, 158, 255, 0.15) 100%);
border-color: #4a9eff;
box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.4);
}
.session-icon {
font-size: 20px;
flex-shrink: 0;
}
.session-info {
flex: 1;
min-width: 0;
}
.session-name {
font-weight: 600;
font-size: 14px;
color: #e0e0e0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 2px;
}
.session-detail {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: #888;
}
.session-time {
color: #888;
}
.session-id {
font-family: 'Monaco', 'Menlo', monospace;
color: #666;
font-size: 11px;
}
/* Status Badges */
.status-badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
flex-shrink: 0;
}
.status-badge.active {
background: rgba(81, 207, 102, 0.15);
color: #51cf66;
border: 1px solid rgba(81, 207, 102, 0.3);
}
.status-badge.done {
background: rgba(136, 136, 136, 0.15);
color: #888;
border: 1px solid rgba(136, 136, 136, 0.3);
}
.status-badge.local {
background: rgba(255, 136, 77, 0.15);
color: #ff874d;
border: 1px solid rgba(255, 136, 77, 0.3);
}
/* Session Sections */
.session-section {
margin-top: 16px;
}
.session-section:first-of-type {
margin-top: 0;
}
.section-header {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: #888;
margin-bottom: 8px;
padding: 0 4px;
letter-spacing: 0.5px;
}
.no-sessions {
text-align: center;
padding: 40px 20px;
color: #666;
}
.no-sessions p {
margin: 0;
font-size: 14px;
}
/* Restore Toast */
.restore-toast {
max-width: 400px;
}
.restore-content {
display: flex;
align-items: flex-start;
gap: 12px;
}
.restore-message {
flex: 1;
}
.restore-message strong {
display: block;
margin-bottom: 4px;
color: #e0e0e0;
}
.restore-message br + span {
font-size: 13px;
color: #888;
}
.restore-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.btn-restore-all,
.btn-dismiss {
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-restore-all {
background: #4a9eff;
border: none;
color: white;
}
.btn-restore-all:hover {
background: #3a8eef;
}
.btn-dismiss {
background: #2a2a2a;
border: 1px solid #444;
color: #e0e0e0;
}
.btn-dismiss:hover {
background: #3a3a3a;
}
/* Responsive */
@media (max-width: 768px) {
.terminal-header {
padding: 10px 12px;
}
.terminal-header h2 {
font-size: 16px;
}
.terminal-toolbar {
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
.terminal-actions {
width: 100%;
justify-content: flex-end;
}
.terminal-path {
max-width: 150px;
}
.session-picker-modal {
max-width: 95%;
}
.session-list-item {
padding: 10px 12px;
}
.session-name {
font-size: 13px;
}
.session-detail {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
}
@media (max-width: 480px) {
.terminal-tab {
padding: 6px 8px;
font-size: 12px;
}
.tab-id {
display: none;
}
.terminal-toolbar {
font-size: 11px;
}
.terminal-path {
max-width: 100px;
}
.session-list-item {
gap: 8px;
padding: 8px 10px;
}
.session-icon {
font-size: 16px;
}
.status-badge {
font-size: 10px;
padding: 3px 8px;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Claude Code</title>
<link rel="stylesheet" href="/claude/css/style.css">
<link rel="stylesheet" href="/claude/claude-ide/sessions-landing.css">
</head>
<body class="sessions-page">
<!-- Hero Section -->
<section class="hero-section">
<h1 class="hero-title">Claude Code</h1>
<p class="hero-subtitle">Start coding</p>
<input
type="text"
id="project-input"
class="project-input"
placeholder="What project are you working on?"
maxlength="50"
autocomplete="off"
/>
<div id="input-status" class="input-status"></div>
</section>
<!-- Projects Section -->
<section class="projects-section">
<div class="projects-header">
<h2 class="projects-title">Projects</h2>
<button class="btn-refresh" onclick="refreshSessions()">
<span>🔄</span> Refresh
</button>
</div>
<!-- Empty State -->
<div id="projects-empty" class="projects-empty" style="display: none;">
No projects yet. Type above to create your first one.
</div>
<!-- Projects Table -->
<div class="projects-table-wrapper">
<table id="projects-table" class="projects-table">
<thead>
<tr>
<th class="col-name">Project Name</th>
<th class="col-activity">Last Activity</th>
<th class="col-status">Status</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody id="projects-tbody">
<!-- Rows rendered dynamically -->
</tbody>
</table>
</div>
</section>
<script src="/claude/js/app.js"></script>
<script src="/claude/claude-ide/sessions-landing.js"></script>
</body>
</html>

486
public/css/style.css Normal file
View File

@@ -0,0 +1,486 @@
/* Reset and Base */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #1e1e1e;
--bg-secondary: #252526;
--bg-tertiary: #2d2d2d;
--text-primary: #e0e0e0;
--text-secondary: #858585;
--accent: #7c3aed;
--accent-hover: #6d28d9;
--border: #3e3e42;
--success: #22c55e;
--error: #ef4444;
--warning: #f59e0b;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
height: 100vh;
overflow: hidden;
}
/* Login Screen */
.login-screen {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
background: linear-gradient(135deg, #1e1e2e 0%, #2d2d44 100%);
}
.login-box {
background: var(--bg-secondary);
padding: 2.5rem;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 100%;
max-width: 400px;
}
.login-box h1 {
font-size: 1.8rem;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.subtitle {
color: var(--text-secondary);
margin-bottom: 2rem;
font-size: 0.95rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.9rem;
color: var(--text-secondary);
}
.form-group input {
width: 100%;
padding: 0.75rem;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 1rem;
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
}
/* Buttons */
.btn-primary,
.btn-secondary,
.btn-logout {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: var(--accent);
color: white;
width: 100%;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-secondary:hover {
background: var(--border);
}
.btn-logout {
background: transparent;
color: var(--text-secondary);
padding: 0.5rem 1rem;
font-size: 0.85rem;
}
.btn-logout:hover {
color: var(--text-primary);
}
.error {
color: var(--error);
margin-top: 1rem;
text-align: center;
font-size: 0.9rem;
}
/* Main App */
.main-app {
display: flex;
height: 100vh;
}
/* Sidebar */
.sidebar {
width: 280px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: 1.5rem;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-header h2 {
font-size: 1.2rem;
color: var(--text-primary);
}
.search-box {
padding: 1rem;
}
.search-box input {
width: 100%;
padding: 0.6rem 0.8rem;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.9rem;
}
.search-box input:focus {
outline: none;
border-color: var(--accent);
}
.sidebar-section {
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.sidebar-section h3 {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin-bottom: 0.75rem;
}
.file-list {
list-style: none;
}
.file-list li {
padding: 0.5rem 0.75rem;
cursor: pointer;
border-radius: 4px;
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-list li:hover {
background: var(--bg-tertiary);
}
.file-tree {
font-size: 0.9rem;
}
.tree-item {
padding: 0.4rem 0.75rem;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
gap: 0.5rem;
}
.tree-item:hover {
background: var(--bg-tertiary);
}
.tree-item.folder {
font-weight: 500;
}
.tree-item.file {
color: var(--text-secondary);
}
.tree-children {
margin-left: 1rem;
}
.tree-icon {
font-size: 0.8rem;
}
/* Main Content */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-primary);
}
.content-header {
padding: 1rem 2rem;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.breadcrumb {
font-size: 0.9rem;
color: var(--text-secondary);
}
.actions {
display: flex;
gap: 0.5rem;
}
.content-view,
.content-editor {
flex: 1;
padding: 2rem;
overflow-y: auto;
}
.content-editor {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.95rem;
line-height: 1.7;
resize: none;
border: none;
background: var(--bg-primary);
color: var(--text-primary);
}
.content-editor:focus {
outline: none;
}
/* Markdown Content */
.markdown-body {
max-width: 800px;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin-top: 1.5rem;
margin-bottom: 1rem;
font-weight: 600;
}
.markdown-body h1 {
font-size: 2rem;
border-bottom: 1px solid var(--border);
padding-bottom: 0.5rem;
}
.markdown-body h2 {
font-size: 1.5rem;
border-bottom: 1px solid var(--border);
padding-bottom: 0.3rem;
}
.markdown-body h3 {
font-size: 1.25rem;
}
.markdown-body p {
margin-bottom: 1rem;
}
.markdown-body a {
color: var(--accent);
text-decoration: none;
}
.markdown-body a:hover {
text-decoration: underline;
}
.markdown-body code {
background: var(--bg-tertiary);
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.9em;
}
.markdown-body pre {
background: var(--bg-tertiary);
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
margin-bottom: 1rem;
}
.markdown-body pre code {
background: none;
padding: 0;
}
.markdown-body ul,
.markdown-body ol {
margin-left: 2rem;
margin-bottom: 1rem;
}
.markdown-body li {
margin-bottom: 0.5rem;
}
.markdown-body blockquote {
border-left: 4px solid var(--accent);
padding-left: 1rem;
margin: 1rem 0;
color: var(--text-secondary);
}
.markdown-body table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
.markdown-body th,
.markdown-body td {
border: 1px solid var(--border);
padding: 0.5rem;
}
.markdown-body th {
background: var(--bg-secondary);
}
.markdown-body img {
max-width: 100%;
border-radius: 6px;
}
.placeholder {
text-align: center;
padding: 4rem 2rem;
color: var(--text-secondary);
}
.placeholder h2 {
margin-bottom: 1rem;
color: var(--text-primary);
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
position: absolute;
left: -280px;
height: 100%;
z-index: 100;
transition: left 0.3s;
}
.sidebar.open {
left: 0;
}
.content-view,
.content-editor {
padding: 1rem;
}
}
/* Loading */
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid var(--border);
border-radius: 50%;
border-top-color: var(--accent);
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Search Results */
.search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
margin-top: 0.5rem;
max-height: 400px;
overflow-y: auto;
z-index: 100;
display: none;
}
.search-results.active {
display: block;
}
.search-result-item {
padding: 0.75rem;
cursor: pointer;
border-bottom: 1px solid var(--border);
}
.search-result-item:hover {
background: var(--bg-tertiary);
}
.search-result-item:last-child {
border-bottom: none;
}
.search-result-title {
font-weight: 500;
margin-bottom: 0.25rem;
}
.search-result-preview {
font-size: 0.85rem;
color: var(--text-secondary);
}

81
public/index.html Normal file
View File

@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Obsidian Web Interface</title>
<link rel="stylesheet" href="/claude/css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
</head>
<body>
<div id="app">
<!-- Login Screen -->
<div id="login-screen" class="login-screen">
<div class="login-box">
<h1>Obsidian Web Interface</h1>
<p class="subtitle">Secure access to your knowledge base</p>
<form id="login-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn-primary">Login</button>
<p id="login-error" class="error"></p>
</form>
</div>
</div>
<!-- Main App -->
<div id="main-app" class="main-app" style="display: none;">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<h2>Obsidian</h2>
<button id="logout-btn" class="btn-logout" title="Logout">Logout</button>
</div>
<div class="search-box">
<input type="text" id="search-input" placeholder="Search notes...">
</div>
<div class="sidebar-section">
<h3>Recent</h3>
<ul id="recent-files" class="file-list"></ul>
</div>
<div class="sidebar-section">
<h3>Files</h3>
<div id="file-tree" class="file-tree"></div>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<div class="content-header">
<div class="breadcrumb" id="breadcrumb"></div>
<div class="actions">
<button id="edit-btn" class="btn-secondary">Edit</button>
<button id="save-btn" class="btn-primary" style="display: none;">Save</button>
<button id="cancel-btn" class="btn-secondary" style="display: none;">Cancel</button>
</div>
</div>
<div id="content-view" class="content-view markdown-body">
<div class="placeholder">
<h2>Welcome to Obsidian Web Interface</h2>
<p>Select a file from the sidebar to view or edit your notes.</p>
</div>
</div>
<textarea id="content-editor" class="content-editor" style="display: none;" placeholder="Start writing..."></textarea>
</main>
</div>
</div>
<script src="/claude/js/app.js"></script>
</body>
</html>

313
public/js/app.js Normal file
View File

@@ -0,0 +1,313 @@
// API Base URL
const API_BASE = '/claude/api';
// State
let currentFile = null;
let isEditing = false;
let fileTree = [];
// DOM Elements
const loginScreen = document.getElementById('login-screen');
const mainApp = document.getElementById('main-app');
const loginForm = document.getElementById('login-form');
const loginError = document.getElementById('login-error');
const logoutBtn = document.getElementById('logout-btn');
const searchInput = document.getElementById('search-input');
const fileTreeEl = document.getElementById('file-tree');
const recentFilesEl = document.getElementById('recent-files');
const contentView = document.getElementById('content-view');
const contentEditor = document.getElementById('content-editor');
const breadcrumb = document.getElementById('breadcrumb');
const editBtn = document.getElementById('edit-btn');
const saveBtn = document.getElementById('save-btn');
const cancelBtn = document.getElementById('cancel-btn');
// Initialize
document.addEventListener('DOMContentLoaded', () => {
checkAuth();
setupEventListeners();
});
function setupEventListeners() {
// Login form
loginForm.addEventListener('submit', handleLogin);
// Logout
logoutBtn.addEventListener('click', handleLogout);
// Search
let searchTimeout;
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => handleSearch(e.target.value), 300);
});
// Edit/Save/Cancel buttons
editBtn.addEventListener('click', startEditing);
saveBtn.addEventListener('click', saveFile);
cancelBtn.addEventListener('click', stopEditing);
}
// Auth functions
async function checkAuth() {
try {
const res = await fetch(`${API_BASE}/auth/status`);
const data = await res.json();
if (data.authenticated) {
// Redirect to landing page if authenticated
window.location.href = '/claude/';
} else {
showLogin();
}
} catch (error) {
console.error('Auth check failed:', error);
showLogin();
}
}
async function handleLogin(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const res = await fetch(`${API_BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (data.success) {
// Redirect to landing page after successful login
window.location.href = '/claude/';
} else {
loginError.textContent = data.error || 'Login failed';
}
} catch (error) {
loginError.textContent = 'Network error. Please try again.';
}
}
async function handleLogout() {
try {
await fetch(`${API_BASE}/logout`, { method: 'POST' });
showLogin();
} catch (error) {
console.error('Logout failed:', error);
}
}
function showLogin() {
loginScreen.style.display = 'flex';
mainApp.style.display = 'none';
}
function showApp() {
loginScreen.style.display = 'none';
mainApp.style.display = 'flex';
loadFileTree();
loadRecentFiles();
}
// File functions
async function loadFileTree() {
try {
const res = await fetch(`${API_BASE}/files`);
const data = await res.json();
fileTree = data.tree;
renderFileTree(fileTree, fileTreeEl);
} catch (error) {
console.error('Failed to load file tree:', error);
}
}
function renderFileTree(tree, container, level = 0) {
container.innerHTML = '';
tree.forEach(item => {
const div = document.createElement('div');
div.className = 'tree-item ' + item.type;
div.style.paddingLeft = (level * 1 + 0.75) + 'rem';
const icon = document.createElement('span');
icon.className = 'tree-icon';
icon.textContent = item.type === 'folder' ? '📁' : '📄';
div.appendChild(icon);
const name = document.createElement('span');
name.textContent = item.name;
div.appendChild(name);
if (item.type === 'folder' && item.children) {
const children = document.createElement('div');
children.className = 'tree-children';
children.style.display = 'none';
div.addEventListener('click', () => {
children.style.display = children.style.display === 'none' ? 'block' : 'none';
icon.textContent = children.style.display === 'none' ? '📁' : '📂';
});
renderFileTree(item.children, children, level + 1);
div.appendChild(children);
} else if (item.type === 'file') {
div.addEventListener('click', () => loadFile(item.path));
}
container.appendChild(div);
});
}
async function loadRecentFiles() {
try {
const res = await fetch(`${API_BASE}/recent?limit=10`);
const data = await res.json();
recentFilesEl.innerHTML = '';
data.files.forEach(file => {
const li = document.createElement('li');
li.textContent = file.name;
li.title = file.path;
li.addEventListener('click', () => loadFile(file.path));
recentFilesEl.appendChild(li);
});
} catch (error) {
console.error('Failed to load recent files:', error);
}
}
async function loadFile(filePath) {
try {
const res = await fetch(`${API_BASE}/file/${encodeURIComponent(filePath)}`);
const data = await res.json();
if (data.error) {
console.error('Error loading file:', data.error);
return;
}
currentFile = data;
breadcrumb.textContent = data.path;
contentView.innerHTML = data.html;
contentEditor.value = data.content;
editBtn.style.display = 'inline-block';
stopEditing();
} catch (error) {
console.error('Failed to load file:', error);
}
}
async function handleSearch(query) {
if (!query.trim()) {
return;
}
try {
const res = await fetch(`${API_BASE}/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
// Show search results in file tree
fileTreeEl.innerHTML = '';
if (data.results.length === 0) {
fileTreeEl.innerHTML = '<div style="padding: 1rem; color: var(--text-secondary);">No results found</div>';
return;
}
data.results.forEach(result => {
const div = document.createElement('div');
div.className = 'tree-item file';
const name = document.createElement('div');
name.textContent = result.name;
name.style.fontWeight = '500';
div.appendChild(name);
const preview = document.createElement('div');
preview.textContent = result.preview;
preview.style.fontSize = '0.8rem';
preview.style.color = 'var(--text-secondary)';
preview.style.marginTop = '0.25rem';
div.appendChild(preview);
div.addEventListener('click', () => loadFile(result.path));
fileTreeEl.appendChild(div);
});
} catch (error) {
console.error('Search failed:', error);
}
}
// Edit functions
function startEditing() {
if (!currentFile) return;
isEditing = true;
contentView.style.display = 'none';
contentEditor.style.display = 'block';
editBtn.style.display = 'none';
saveBtn.style.display = 'inline-block';
cancelBtn.style.display = 'inline-block';
contentEditor.focus();
}
function stopEditing() {
isEditing = false;
contentView.style.display = 'block';
contentEditor.style.display = 'none';
editBtn.style.display = 'inline-block';
saveBtn.style.display = 'none';
cancelBtn.style.display = 'none';
}
async function saveFile() {
if (!currentFile || !isEditing) return;
const content = contentEditor.value;
try {
const res = await fetch(`${API_BASE}/file/${encodeURIComponent(currentFile.path)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content })
});
const data = await res.json();
if (data.success) {
// Reload the file to show updated content
await loadFile(currentFile.path);
} else {
alert('Failed to save: ' + (data.error || 'Unknown error'));
}
} catch (error) {
console.error('Failed to save file:', error);
alert('Failed to save file. Please try again.');
}
}
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Ctrl/Cmd + S to save
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
if (isEditing) {
saveFile();
}
}
// Escape to cancel editing
if (e.key === 'Escape' && isEditing) {
stopEditing();
}
// Ctrl/Cmd + E to edit
if ((e.ctrlKey || e.metaKey) && e.key === 'e' && !isEditing && currentFile) {
e.preventDefault();
startEditing();
}
});