feat: Add Notifications Component with WebSocket and Auto Refresh

- Implemented a Notifications component for real-time updates using WebSocket.
- Added silent refresh functionality to update data without notification bubbles.
- Introduced auto-refresh mechanism to periodically check for changes in workflow data.
- Enhanced data handling with session and task updates, ensuring UI reflects the latest state.

feat: Create Hook Manager View for Managing Hooks

- Developed a Hook Manager view to manage project and global hooks.
- Added functionality to create, edit, and delete hooks with a user-friendly interface.
- Implemented quick install templates for common hooks to streamline user experience.
- Included environment variables reference for hooks to assist users in configuration.

feat: Implement MCP Manager View for Server Management

- Created an MCP Manager view for managing MCP servers within projects.
- Enabled adding and removing servers from projects with a clear UI.
- Displayed available servers from other projects for easy access and management.
- Provided an overview of all projects and their associated MCP servers.

feat: Add Version Fetcher Utility for GitHub Releases

- Implemented a version fetcher utility to retrieve release information from GitHub.
- Added functions to fetch the latest release, recent releases, and latest commit details.
- Included functionality to download and extract repository zip files.
- Ensured cleanup of temporary directories after downloads to maintain system hygiene.
This commit is contained in:
catlog22
2025-12-07 15:48:39 +08:00
parent 724545ebd6
commit 43c962b48b
18 changed files with 4250 additions and 42 deletions

View File

@@ -0,0 +1,349 @@
// ==========================================
// CAROUSEL COMPONENT
// ==========================================
// Active session carousel with detailed task info and smooth transitions
let carouselIndex = 0;
let carouselSessions = [];
let carouselInterval = null;
let carouselPaused = false;
const CAROUSEL_INTERVAL_MS = 5000; // 5 seconds
function initCarousel() {
const prevBtn = document.getElementById('carouselPrev');
const nextBtn = document.getElementById('carouselNext');
const pauseBtn = document.getElementById('carouselPause');
if (prevBtn) {
prevBtn.addEventListener('click', () => {
carouselPrev();
resetCarouselInterval();
});
}
if (nextBtn) {
nextBtn.addEventListener('click', () => {
carouselNext();
resetCarouselInterval();
});
}
if (pauseBtn) {
pauseBtn.addEventListener('click', toggleCarouselPause);
}
}
function updateCarousel() {
// Get active sessions from workflowData
const previousSessions = carouselSessions;
const previousIndex = carouselIndex;
const previousSessionId = previousSessions[previousIndex]?.session_id;
carouselSessions = workflowData.activeSessions || [];
// Try to preserve current position
if (previousSessionId && carouselSessions.length > 0) {
// Find if the same session still exists
const newIndex = carouselSessions.findIndex(s => s.session_id === previousSessionId);
if (newIndex !== -1) {
carouselIndex = newIndex;
} else if (previousIndex < carouselSessions.length) {
// Keep same index if valid
carouselIndex = previousIndex;
} else {
// Reset to last valid index
carouselIndex = Math.max(0, carouselSessions.length - 1);
}
} else {
carouselIndex = 0;
}
renderCarouselDots();
renderCarouselSlide('none');
startCarouselInterval();
}
function renderCarouselDots() {
const dotsContainer = document.getElementById('carouselDots');
if (!dotsContainer) return;
if (carouselSessions.length === 0) {
dotsContainer.innerHTML = '';
return;
}
dotsContainer.innerHTML = carouselSessions.map((_, index) => `
<button class="carousel-dot w-2 h-2 rounded-full transition-all duration-200 ${index === carouselIndex ? 'bg-primary w-4' : 'bg-muted-foreground/40 hover:bg-muted-foreground/60'}"
onclick="carouselGoToIndex(${index})" title="Session ${index + 1}"></button>
`).join('');
}
function updateActiveDot() {
const dots = document.querySelectorAll('.carousel-dot');
dots.forEach((dot, index) => {
if (index === carouselIndex) {
dot.classList.remove('bg-muted-foreground/40', 'hover:bg-muted-foreground/60', 'w-2');
dot.classList.add('bg-primary', 'w-4');
} else {
dot.classList.remove('bg-primary', 'w-4');
dot.classList.add('bg-muted-foreground/40', 'hover:bg-muted-foreground/60', 'w-2');
}
});
}
function carouselGoToIndex(index) {
if (index < 0 || index >= carouselSessions.length) return;
const direction = index > carouselIndex ? 'left' : (index < carouselIndex ? 'right' : 'none');
carouselIndex = index;
renderCarouselSlide(direction);
updateActiveDot();
resetCarouselInterval();
}
function renderCarouselSlide(direction = 'none') {
const container = document.getElementById('carouselContent');
if (!container) return;
if (carouselSessions.length === 0) {
container.innerHTML = `
<div class="carousel-empty flex items-center justify-center h-full text-muted-foreground">
<div class="text-center">
<div class="text-3xl mb-2">🎯</div>
<p class="text-sm">No active sessions</p>
</div>
</div>
`;
return;
}
const session = carouselSessions[carouselIndex];
const tasks = session.tasks || [];
const completed = tasks.filter(t => t.status === 'completed').length;
const inProgress = tasks.filter(t => t.status === 'in_progress').length;
const pending = tasks.filter(t => t.status === 'pending').length;
const taskCount = session.taskCount || tasks.length;
const progress = taskCount > 0 ? Math.round((completed / taskCount) * 100) : 0;
// Get session type badge
const sessionType = session.type || 'workflow';
const typeBadgeClass = getSessionTypeBadgeClass(sessionType);
const sessionKey = `session-${session.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-');
// Animation class based on direction
const animClass = direction === 'left' ? 'carousel-slide-left' :
direction === 'right' ? 'carousel-slide-right' : 'carousel-fade-in';
// Get recent task activity
const recentTasks = getRecentTaskActivity(tasks);
// Format timestamps
const createdTime = session.created_at ? formatRelativeTime(session.created_at) : '';
const updatedTime = session.updated_at ? formatRelativeTime(session.updated_at) : '';
// Get more tasks for display (up to 4)
const displayTasks = getRecentTaskActivity(tasks, 4);
container.innerHTML = `
<div class="carousel-slide ${animClass} h-full">
<div class="session-card h-full p-3 cursor-pointer hover:bg-hover/30 transition-colors"
onclick="showSessionDetailPage('${sessionKey}')">
<!-- Two Column Layout -->
<div class="flex gap-4 h-full">
<!-- Left Column: Session Info -->
<div class="flex-1 flex flex-col min-w-0">
<!-- Session Header -->
<div class="flex items-center gap-2 mb-2 flex-wrap">
<span class="px-2 py-0.5 text-xs font-medium rounded ${typeBadgeClass}">${sessionType}</span>
${inProgress > 0 ? `<span class="inline-flex items-center gap-1 text-xs text-warning"><span class="w-2 h-2 rounded-full bg-warning animate-pulse"></span>${inProgress} running</span>` : ''}
</div>
<h4 class="font-semibold text-foreground text-sm line-clamp-1 mb-2" title="${escapeHtml(session.session_id)}">${escapeHtml(session.session_id)}</h4>
<!-- Progress -->
<div class="mb-2">
<div class="flex items-center justify-between text-xs mb-1">
<span class="text-muted-foreground">Progress</span>
<span class="text-foreground font-medium">${completed}/${taskCount}</span>
</div>
<div class="h-1.5 bg-muted rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500 ${progress === 100 ? 'bg-success' : 'bg-primary'}" style="width: ${progress}%"></div>
</div>
</div>
<!-- Task Status Summary -->
<div class="flex items-center gap-3 text-xs mb-2">
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-success"></span>${completed}</span>
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-warning ${inProgress > 0 ? 'animate-pulse' : ''}"></span>${inProgress}</span>
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-muted-foreground"></span>${pending}</span>
</div>
<!-- Footer -->
<div class="mt-auto flex items-center gap-3 text-xs text-muted-foreground">
<span>📅 ${createdTime}</span>
${updatedTime && updatedTime !== createdTime ? `<span>🔄 ${updatedTime}</span>` : ''}
</div>
</div>
<!-- Right Column: Task List -->
<div class="w-[45%] flex flex-col border-l border-border pl-3">
<div class="text-xs font-medium text-muted-foreground mb-1.5">Recent Tasks</div>
<div class="task-list flex-1 space-y-1 overflow-hidden">
${displayTasks.length > 0 ? displayTasks.map(task => `
<div class="flex items-center gap-1.5 text-xs">
<span class="shrink-0">${getTaskStatusEmoji(task.status)}</span>
<span class="truncate flex-1 ${task.status === 'in_progress' ? 'text-foreground font-medium' : 'text-muted-foreground'}">${escapeHtml(task.title || task.id || 'Task')}</span>
</div>
`).join('') : `
<div class="text-xs text-muted-foreground">No tasks yet</div>
`}
</div>
<!-- Progress percentage -->
<div class="mt-auto pt-1 text-right">
<span class="text-xl font-bold ${progress === 100 ? 'text-success' : 'text-primary'}">${progress}%</span>
</div>
</div>
</div>
</div>
</div>
`;
// Store session data for navigation
if (!sessionDataStore[sessionKey]) {
sessionDataStore[sessionKey] = session;
}
}
function getSessionTypeBadgeClass(type) {
const classes = {
'tdd': 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
'review': 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
'test': 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
'docs': 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
'workflow': 'bg-primary-light text-primary'
};
return classes[type] || classes['workflow'];
}
function getRecentTaskActivity(tasks, limit = 4) {
if (!tasks || tasks.length === 0) return [];
// Get in_progress tasks first, then most recently updated
const sorted = [...tasks].sort((a, b) => {
// in_progress first
if (a.status === 'in_progress' && b.status !== 'in_progress') return -1;
if (b.status === 'in_progress' && a.status !== 'in_progress') return 1;
// Then by updated_at
const timeA = a.updated_at || a.created_at || '';
const timeB = b.updated_at || b.created_at || '';
return timeB.localeCompare(timeA);
});
// Return top N tasks
return sorted.slice(0, limit);
}
function getTaskStatusEmoji(status) {
const emojis = {
'completed': '✅',
'in_progress': '🔄',
'pending': '⏸️',
'blocked': '🚫'
};
return emojis[status] || '📋';
}
function getTaskStatusIcon(status) {
return status === 'in_progress' ? 'animate-spin-slow' : '';
}
function formatRelativeTime(dateString) {
if (!dateString) return '';
try {
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
// Format as date for older
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
} catch (e) {
return dateString;
}
}
function carouselNext() {
if (carouselSessions.length === 0) return;
carouselIndex = (carouselIndex + 1) % carouselSessions.length;
renderCarouselSlide('left');
updateActiveDot();
}
function carouselPrev() {
if (carouselSessions.length === 0) return;
carouselIndex = (carouselIndex - 1 + carouselSessions.length) % carouselSessions.length;
renderCarouselSlide('right');
updateActiveDot();
}
function startCarouselInterval() {
stopCarouselInterval();
if (!carouselPaused && carouselSessions.length > 1) {
carouselInterval = setInterval(carouselNext, CAROUSEL_INTERVAL_MS);
}
}
function stopCarouselInterval() {
if (carouselInterval) {
clearInterval(carouselInterval);
carouselInterval = null;
}
}
function resetCarouselInterval() {
if (!carouselPaused) {
startCarouselInterval();
}
}
function toggleCarouselPause() {
carouselPaused = !carouselPaused;
const icon = document.getElementById('carouselPauseIcon');
if (carouselPaused) {
stopCarouselInterval();
// Change to play icon
if (icon) {
icon.innerHTML = '<polygon points="5 3 19 12 5 21 5 3"/>';
}
} else {
startCarouselInterval();
// Change to pause icon
if (icon) {
icon.innerHTML = '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>';
}
}
}
// Jump to specific session in carousel
function carouselGoTo(sessionId) {
const index = carouselSessions.findIndex(s => s.session_id === sessionId);
if (index !== -1) {
carouselIndex = index;
renderCarouselSlide('none');
updateActiveDot();
resetCarouselInterval();
}
}

View File

@@ -0,0 +1,273 @@
// Hook Manager Component
// Manages Claude Code hooks configuration from settings.json
// ========== Hook State ==========
let hookConfig = {
global: { hooks: {} },
project: { hooks: {} }
};
// ========== Hook Templates ==========
const HOOK_TEMPLATES = {
'ccw-notify': {
event: 'PostToolUse',
matcher: 'Write',
command: 'curl',
args: ['-s', '-X', 'POST', '-H', 'Content-Type: application/json', '-d', '{"type":"summary_written","filePath":"$CLAUDE_FILE_PATHS"}', 'http://localhost:3456/api/hook']
},
'log-tool': {
event: 'PostToolUse',
matcher: '',
command: 'bash',
args: ['-c', 'echo "[$(date)] Tool: $CLAUDE_TOOL_NAME, Files: $CLAUDE_FILE_PATHS" >> ~/.claude/tool-usage.log']
},
'lint-check': {
event: 'PostToolUse',
matcher: 'Write',
command: 'bash',
args: ['-c', 'for f in $CLAUDE_FILE_PATHS; do if [[ "$f" =~ \\.(js|ts|jsx|tsx)$ ]]; then npx eslint "$f" --fix 2>/dev/null || true; fi; done']
},
'git-add': {
event: 'PostToolUse',
matcher: 'Write',
command: 'bash',
args: ['-c', 'for f in $CLAUDE_FILE_PATHS; do git add "$f" 2>/dev/null || true; done']
}
};
// ========== Initialization ==========
function initHookManager() {
// Initialize Hook navigation
document.querySelectorAll('.nav-item[data-view="hook-manager"]').forEach(item => {
item.addEventListener('click', () => {
setActiveNavItem(item);
currentView = 'hook-manager';
currentFilter = null;
currentLiteType = null;
currentSessionDetailKey = null;
updateContentTitle();
renderHookManager();
});
});
}
// ========== Data Loading ==========
async function loadHookConfig() {
try {
const response = await fetch(`/api/hooks?path=${encodeURIComponent(projectPath)}`);
if (!response.ok) throw new Error('Failed to load hook config');
const data = await response.json();
hookConfig = data;
updateHookBadge();
return data;
} catch (err) {
console.error('Failed to load hook config:', err);
return null;
}
}
async function saveHook(scope, event, hookData) {
try {
const response = await fetch('/api/hooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectPath: projectPath,
scope: scope,
event: event,
hookData: hookData
})
});
if (!response.ok) throw new Error('Failed to save hook');
const result = await response.json();
if (result.success) {
await loadHookConfig();
renderHookManager();
showRefreshToast(`Hook saved successfully`, 'success');
}
return result;
} catch (err) {
console.error('Failed to save hook:', err);
showRefreshToast(`Failed to save hook: ${err.message}`, 'error');
return null;
}
}
async function removeHook(scope, event, hookIndex) {
try {
const response = await fetch('/api/hooks', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectPath: projectPath,
scope: scope,
event: event,
hookIndex: hookIndex
})
});
if (!response.ok) throw new Error('Failed to remove hook');
const result = await response.json();
if (result.success) {
await loadHookConfig();
renderHookManager();
showRefreshToast(`Hook removed successfully`, 'success');
}
return result;
} catch (err) {
console.error('Failed to remove hook:', err);
showRefreshToast(`Failed to remove hook: ${err.message}`, 'error');
return null;
}
}
// ========== Badge Update ==========
function updateHookBadge() {
const badge = document.getElementById('badgeHooks');
if (badge) {
let totalHooks = 0;
// Count global hooks
if (hookConfig.global?.hooks) {
for (const event of Object.keys(hookConfig.global.hooks)) {
const hooks = hookConfig.global.hooks[event];
totalHooks += Array.isArray(hooks) ? hooks.length : 1;
}
}
// Count project hooks
if (hookConfig.project?.hooks) {
for (const event of Object.keys(hookConfig.project.hooks)) {
const hooks = hookConfig.project.hooks[event];
totalHooks += Array.isArray(hooks) ? hooks.length : 1;
}
}
badge.textContent = totalHooks;
}
}
// ========== Hook Modal Functions ==========
let editingHookData = null;
function openHookCreateModal(editData = null) {
const modal = document.getElementById('hookCreateModal');
const title = document.getElementById('hookModalTitle');
if (modal) {
modal.classList.remove('hidden');
editingHookData = editData;
// Set title based on mode
title.textContent = editData ? 'Edit Hook' : 'Create Hook';
// Clear or populate form
if (editData) {
document.getElementById('hookEvent').value = editData.event || '';
document.getElementById('hookMatcher').value = editData.matcher || '';
document.getElementById('hookCommand').value = editData.command || '';
document.getElementById('hookArgs').value = (editData.args || []).join('\n');
// Set scope radio
const scopeRadio = document.querySelector(`input[name="hookScope"][value="${editData.scope || 'project'}"]`);
if (scopeRadio) scopeRadio.checked = true;
} else {
document.getElementById('hookEvent').value = '';
document.getElementById('hookMatcher').value = '';
document.getElementById('hookCommand').value = '';
document.getElementById('hookArgs').value = '';
document.querySelector('input[name="hookScope"][value="project"]').checked = true;
}
// Focus on event select
document.getElementById('hookEvent').focus();
}
}
function closeHookCreateModal() {
const modal = document.getElementById('hookCreateModal');
if (modal) {
modal.classList.add('hidden');
editingHookData = null;
}
}
function applyHookTemplate(templateName) {
const template = HOOK_TEMPLATES[templateName];
if (!template) return;
document.getElementById('hookEvent').value = template.event;
document.getElementById('hookMatcher').value = template.matcher;
document.getElementById('hookCommand').value = template.command;
document.getElementById('hookArgs').value = template.args.join('\n');
}
async function submitHookCreate() {
const event = document.getElementById('hookEvent').value;
const matcher = document.getElementById('hookMatcher').value.trim();
const command = document.getElementById('hookCommand').value.trim();
const argsText = document.getElementById('hookArgs').value.trim();
const scope = document.querySelector('input[name="hookScope"]:checked').value;
// Validate required fields
if (!event) {
showRefreshToast('Hook event is required', 'error');
document.getElementById('hookEvent').focus();
return;
}
if (!command) {
showRefreshToast('Command is required', 'error');
document.getElementById('hookCommand').focus();
return;
}
// Parse args (one per line)
const args = argsText ? argsText.split('\n').map(a => a.trim()).filter(a => a) : [];
// Build hook data
const hookData = {
command: command
};
if (args.length > 0) {
hookData.args = args;
}
if (matcher) {
hookData.matcher = matcher;
}
// If editing, include original index for replacement
if (editingHookData && editingHookData.index !== undefined) {
hookData.replaceIndex = editingHookData.index;
}
// Submit to API
await saveHook(scope, event, hookData);
closeHookCreateModal();
}
// ========== Helpers ==========
function getHookEventDescription(event) {
const descriptions = {
'PreToolUse': 'Runs before a tool is executed',
'PostToolUse': 'Runs after a tool completes',
'Notification': 'Runs when a notification is triggered',
'Stop': 'Runs when the agent stops'
};
return descriptions[event] || event;
}
function getHookEventIcon(event) {
const icons = {
'PreToolUse': '⏳',
'PostToolUse': '✅',
'Notification': '🔔',
'Stop': '🛑'
};
return icons[event] || '🪝';
}

View File

@@ -0,0 +1,285 @@
// MCP Manager Component
// Manages MCP server configuration from .claude.json
// ========== MCP State ==========
let mcpConfig = null;
let mcpAllProjects = {};
let mcpCurrentProjectServers = {};
// ========== Initialization ==========
function initMcpManager() {
// Initialize MCP navigation
document.querySelectorAll('.nav-item[data-view="mcp-manager"]').forEach(item => {
item.addEventListener('click', () => {
setActiveNavItem(item);
currentView = 'mcp-manager';
currentFilter = null;
currentLiteType = null;
currentSessionDetailKey = null;
updateContentTitle();
renderMcpManager();
});
});
}
// ========== Data Loading ==========
async function loadMcpConfig() {
try {
const response = await fetch('/api/mcp-config');
if (!response.ok) throw new Error('Failed to load MCP config');
const data = await response.json();
mcpConfig = data;
mcpAllProjects = data.projects || {};
// Get current project servers
const currentPath = projectPath.replace(/\//g, '\\');
mcpCurrentProjectServers = mcpAllProjects[currentPath]?.mcpServers || {};
// Update badge count
updateMcpBadge();
return data;
} catch (err) {
console.error('Failed to load MCP config:', err);
return null;
}
}
async function toggleMcpServer(serverName, enable) {
try {
const response = await fetch('/api/mcp-toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectPath: projectPath,
serverName: serverName,
enable: enable
})
});
if (!response.ok) throw new Error('Failed to toggle MCP server');
const result = await response.json();
if (result.success) {
// Reload config and re-render
await loadMcpConfig();
renderMcpManager();
showRefreshToast(`MCP server "${serverName}" ${enable ? 'enabled' : 'disabled'}`, 'success');
}
return result;
} catch (err) {
console.error('Failed to toggle MCP server:', err);
showRefreshToast(`Failed to toggle MCP server: ${err.message}`, 'error');
return null;
}
}
async function copyMcpServerToProject(serverName, serverConfig) {
try {
const response = await fetch('/api/mcp-copy-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectPath: projectPath,
serverName: serverName,
serverConfig: serverConfig
})
});
if (!response.ok) throw new Error('Failed to copy MCP server');
const result = await response.json();
if (result.success) {
await loadMcpConfig();
renderMcpManager();
showRefreshToast(`MCP server "${serverName}" added to project`, 'success');
}
return result;
} catch (err) {
console.error('Failed to copy MCP server:', err);
showRefreshToast(`Failed to add MCP server: ${err.message}`, 'error');
return null;
}
}
async function removeMcpServerFromProject(serverName) {
try {
const response = await fetch('/api/mcp-remove-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectPath: projectPath,
serverName: serverName
})
});
if (!response.ok) throw new Error('Failed to remove MCP server');
const result = await response.json();
if (result.success) {
await loadMcpConfig();
renderMcpManager();
showRefreshToast(`MCP server "${serverName}" removed from project`, 'success');
}
return result;
} catch (err) {
console.error('Failed to remove MCP server:', err);
showRefreshToast(`Failed to remove MCP server: ${err.message}`, 'error');
return null;
}
}
// ========== Badge Update ==========
function updateMcpBadge() {
const badge = document.getElementById('badgeMcpServers');
if (badge) {
const currentPath = projectPath.replace(/\//g, '\\');
const projectData = mcpAllProjects[currentPath];
const servers = projectData?.mcpServers || {};
const disabledServers = projectData?.disabledMcpServers || [];
const totalServers = Object.keys(servers).length;
const enabledServers = totalServers - disabledServers.length;
badge.textContent = `${enabledServers}/${totalServers}`;
}
}
// ========== Helpers ==========
function getAllAvailableMcpServers() {
const allServers = {};
// Collect servers from all projects
for (const [path, config] of Object.entries(mcpAllProjects)) {
const servers = config.mcpServers || {};
for (const [name, serverConfig] of Object.entries(servers)) {
if (!allServers[name]) {
allServers[name] = {
config: serverConfig,
usedIn: []
};
}
allServers[name].usedIn.push(path);
}
}
return allServers;
}
function isServerEnabledInCurrentProject(serverName) {
const currentPath = projectPath.replace(/\//g, '\\');
const projectData = mcpAllProjects[currentPath];
if (!projectData) return false;
const disabledServers = projectData.disabledMcpServers || [];
return !disabledServers.includes(serverName);
}
function isServerInCurrentProject(serverName) {
const currentPath = projectPath.replace(/\//g, '\\');
const projectData = mcpAllProjects[currentPath];
if (!projectData) return false;
const servers = projectData.mcpServers || {};
return serverName in servers;
}
// ========== MCP Create Modal ==========
function openMcpCreateModal() {
const modal = document.getElementById('mcpCreateModal');
if (modal) {
modal.classList.remove('hidden');
// Clear form
document.getElementById('mcpServerName').value = '';
document.getElementById('mcpServerCommand').value = '';
document.getElementById('mcpServerArgs').value = '';
document.getElementById('mcpServerEnv').value = '';
// Focus on name input
document.getElementById('mcpServerName').focus();
}
}
function closeMcpCreateModal() {
const modal = document.getElementById('mcpCreateModal');
if (modal) {
modal.classList.add('hidden');
}
}
async function submitMcpCreate() {
const name = document.getElementById('mcpServerName').value.trim();
const command = document.getElementById('mcpServerCommand').value.trim();
const argsText = document.getElementById('mcpServerArgs').value.trim();
const envText = document.getElementById('mcpServerEnv').value.trim();
// Validate required fields
if (!name) {
showRefreshToast('Server name is required', 'error');
document.getElementById('mcpServerName').focus();
return;
}
if (!command) {
showRefreshToast('Command is required', 'error');
document.getElementById('mcpServerCommand').focus();
return;
}
// Parse args (one per line)
const args = argsText ? argsText.split('\n').map(a => a.trim()).filter(a => a) : [];
// Parse env vars (KEY=VALUE per line)
const env = {};
if (envText) {
envText.split('\n').forEach(line => {
const trimmed = line.trim();
if (trimmed && trimmed.includes('=')) {
const eqIndex = trimmed.indexOf('=');
const key = trimmed.substring(0, eqIndex).trim();
const value = trimmed.substring(eqIndex + 1).trim();
if (key) {
env[key] = value;
}
}
});
}
// Build server config
const serverConfig = {
command: command,
args: args
};
// Only add env if there are values
if (Object.keys(env).length > 0) {
serverConfig.env = env;
}
// Submit to API
try {
const response = await fetch('/api/mcp-copy-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectPath: projectPath,
serverName: name,
serverConfig: serverConfig
})
});
if (!response.ok) throw new Error('Failed to create MCP server');
const result = await response.json();
if (result.success) {
closeMcpCreateModal();
await loadMcpConfig();
renderMcpManager();
showRefreshToast(`MCP server "${name}" created successfully`, 'success');
} else {
showRefreshToast(result.error || 'Failed to create MCP server', 'error');
}
} catch (err) {
console.error('Failed to create MCP server:', err);
showRefreshToast(`Failed to create MCP server: ${err.message}`, 'error');
}
}

View File

@@ -60,7 +60,7 @@ function initNavigation() {
});
});
// Project Overview Navigation
// View Navigation (Project Overview, MCP Manager, etc.)
document.querySelectorAll('.nav-item[data-view]').forEach(item => {
item.addEventListener('click', () => {
setActiveNavItem(item);
@@ -69,7 +69,13 @@ function initNavigation() {
currentLiteType = null;
currentSessionDetailKey = null;
updateContentTitle();
renderProjectOverview();
// Route to appropriate view
if (currentView === 'mcp-manager') {
renderMcpManager();
} else if (currentView === 'project-overview') {
renderProjectOverview();
}
});
});
}
@@ -83,6 +89,8 @@ function updateContentTitle() {
const titleEl = document.getElementById('contentTitle');
if (currentView === 'project-overview') {
titleEl.textContent = 'Project Overview';
} else if (currentView === 'mcp-manager') {
titleEl.textContent = 'MCP Server Management';
} else if (currentView === 'liteTasks') {
const names = { 'lite-plan': 'Lite Plan Sessions', 'lite-fix': 'Lite Fix Sessions' };
titleEl.textContent = names[currentLiteType] || 'Lite Tasks';

View File

@@ -0,0 +1,194 @@
// ==========================================
// NOTIFICATIONS COMPONENT
// ==========================================
// Real-time silent refresh (no notification bubbles)
let wsConnection = null;
let autoRefreshInterval = null;
let lastDataHash = null;
const AUTO_REFRESH_INTERVAL_MS = 30000; // 30 seconds
// ========== WebSocket Connection ==========
function initWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
try {
wsConnection = new WebSocket(wsUrl);
wsConnection.onopen = () => {
console.log('[WS] Connected');
};
wsConnection.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleNotification(data);
} catch (e) {
console.error('[WS] Failed to parse message:', e);
}
};
wsConnection.onclose = () => {
console.log('[WS] Disconnected, reconnecting in 5s...');
setTimeout(initWebSocket, 5000);
};
wsConnection.onerror = (error) => {
console.error('[WS] Error:', error);
};
} catch (e) {
console.log('[WS] WebSocket not available, using polling');
}
}
// ========== Notification Handler ==========
function handleNotification(data) {
const { type, payload } = data;
// Silent refresh - no notification bubbles
switch (type) {
case 'session_updated':
case 'summary_written':
case 'task_completed':
case 'new_session':
// Just refresh data silently
refreshIfNeeded();
// Optionally highlight in carousel if it's the current session
if (payload.sessionId && typeof carouselGoTo === 'function') {
carouselGoTo(payload.sessionId);
}
break;
default:
console.log('[WS] Unknown notification type:', type);
}
}
// ========== Auto Refresh ==========
function initAutoRefresh() {
// Calculate initial hash
lastDataHash = calculateDataHash();
// Start polling interval
autoRefreshInterval = setInterval(checkForChanges, AUTO_REFRESH_INTERVAL_MS);
}
function calculateDataHash() {
if (!workflowData) return null;
// Simple hash based on key data points
const hashData = {
activeSessions: (workflowData.activeSessions || []).length,
archivedSessions: (workflowData.archivedSessions || []).length,
totalTasks: workflowData.statistics?.totalTasks || 0,
completedTasks: workflowData.statistics?.completedTasks || 0,
generatedAt: workflowData.generatedAt
};
return JSON.stringify(hashData);
}
async function checkForChanges() {
if (!window.SERVER_MODE) return;
try {
const response = await fetch(`/api/data?path=${encodeURIComponent(projectPath)}`);
if (!response.ok) return;
const newData = await response.json();
const newHash = JSON.stringify({
activeSessions: (newData.activeSessions || []).length,
archivedSessions: (newData.archivedSessions || []).length,
totalTasks: newData.statistics?.totalTasks || 0,
completedTasks: newData.statistics?.completedTasks || 0,
generatedAt: newData.generatedAt
});
if (newHash !== lastDataHash) {
lastDataHash = newHash;
// Silent refresh - no notification
await refreshWorkspaceData(newData);
}
} catch (e) {
console.error('[AutoRefresh] Check failed:', e);
}
}
async function refreshIfNeeded() {
if (!window.SERVER_MODE) return;
try {
const response = await fetch(`/api/data?path=${encodeURIComponent(projectPath)}`);
if (!response.ok) return;
const newData = await response.json();
await refreshWorkspaceData(newData);
} catch (e) {
console.error('[Refresh] Failed:', e);
}
}
async function refreshWorkspaceData(newData) {
// Update global data
window.workflowData = newData;
// Clear and repopulate stores
Object.keys(sessionDataStore).forEach(k => delete sessionDataStore[k]);
Object.keys(liteTaskDataStore).forEach(k => delete liteTaskDataStore[k]);
[...(newData.activeSessions || []), ...(newData.archivedSessions || [])].forEach(s => {
const key = `session-${s.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-');
sessionDataStore[key] = s;
});
[...(newData.liteTasks?.litePlan || []), ...(newData.liteTasks?.liteFix || [])].forEach(s => {
const key = `lite-${s.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-');
liteTaskDataStore[key] = s;
});
// Update UI silently
updateStats();
updateBadges();
updateCarousel();
// Re-render current view if needed
if (currentView === 'sessions') {
renderSessions();
} else if (currentView === 'liteTasks') {
renderLiteTasks();
}
lastDataHash = calculateDataHash();
}
// ========== Cleanup ==========
function stopAutoRefresh() {
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
autoRefreshInterval = null;
}
}
function closeWebSocket() {
if (wsConnection) {
wsConnection.close();
wsConnection = null;
}
}
// ========== Navigation Helper ==========
function goToSession(sessionId) {
// Find session in carousel and navigate
const sessionKey = `session-${sessionId}`.replace(/[^a-zA-Z0-9-]/g, '-');
// Jump to session in carousel if visible
if (typeof carouselGoTo === 'function') {
carouselGoTo(sessionId);
}
// Navigate to session detail
if (sessionDataStore[sessionKey]) {
showSessionDetailPage(sessionKey);
}
}

View File

@@ -9,6 +9,13 @@ document.addEventListener('DOMContentLoaded', async () => {
try { initNavigation(); } catch (e) { console.error('Navigation init failed:', e); }
try { initSearch(); } catch (e) { console.error('Search init failed:', e); }
try { initRefreshButton(); } catch (e) { console.error('Refresh button init failed:', e); }
try { initCarousel(); } catch (e) { console.error('Carousel init failed:', e); }
try { initMcpManager(); } catch (e) { console.error('MCP Manager init failed:', e); }
try { initHookManager(); } catch (e) { console.error('Hook Manager init failed:', e); }
// Initialize real-time features (WebSocket + auto-refresh)
try { initWebSocket(); } catch (e) { console.log('WebSocket not available:', e.message); }
try { initAutoRefresh(); } catch (e) { console.error('Auto-refresh init failed:', e); }
// Server mode: load data from API
try {
@@ -35,6 +42,16 @@ document.addEventListener('DOMContentLoaded', async () => {
// Close path modal if exists
closePathModal();
// Close MCP create modal if exists
if (typeof closeMcpCreateModal === 'function') {
closeMcpCreateModal();
}
// Close Hook create modal if exists
if (typeof closeHookCreateModal === 'function') {
closeHookCreateModal();
}
}
});
});

View File

@@ -3,12 +3,23 @@
// ==========================================
function renderDashboard() {
// Show stats grid and search (may be hidden by MCP view)
showStatsAndSearch();
updateStats();
updateBadges();
updateCarousel();
renderSessions();
document.getElementById('generatedAt').textContent = workflowData.generatedAt || new Date().toISOString();
}
function showStatsAndSearch() {
const statsGrid = document.getElementById('statsGrid');
const searchInput = document.getElementById('searchInput');
if (statsGrid) statsGrid.style.display = '';
if (searchInput) searchInput.parentElement.style.display = '';
}
function updateStats() {
const stats = workflowData.statistics || {};
document.getElementById('statTotalSessions').textContent = stats.totalSessions || 0;
@@ -29,6 +40,15 @@ function updateBadges() {
const liteTasks = workflowData.liteTasks || {};
document.getElementById('badgeLitePlan').textContent = liteTasks.litePlan?.length || 0;
document.getElementById('badgeLiteFix').textContent = liteTasks.liteFix?.length || 0;
// MCP badge - load async if needed
if (typeof loadMcpConfig === 'function') {
loadMcpConfig().then(() => {
if (typeof updateMcpBadge === 'function') {
updateMcpBadge();
}
}).catch(e => console.error('MCP badge update failed:', e));
}
}
function renderSessions() {

View File

@@ -0,0 +1,387 @@
// Hook Manager View
// Renders the Claude Code hooks management interface
async function renderHookManager() {
const container = document.getElementById('mainContent');
if (!container) return;
// Hide stats grid and search for Hook view
const statsGrid = document.getElementById('statsGrid');
const searchInput = document.getElementById('searchInput');
if (statsGrid) statsGrid.style.display = 'none';
if (searchInput) searchInput.parentElement.style.display = 'none';
// Load hook config if not already loaded
if (!hookConfig.global.hooks && !hookConfig.project.hooks) {
await loadHookConfig();
}
const globalHooks = hookConfig.global?.hooks || {};
const projectHooks = hookConfig.project?.hooks || {};
// Count hooks
const globalHookCount = countHooks(globalHooks);
const projectHookCount = countHooks(projectHooks);
container.innerHTML = `
<div class="hook-manager">
<!-- Project Hooks -->
<div class="hook-section mb-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<h3 class="text-lg font-semibold text-foreground">Project Hooks</h3>
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-primary-light text-primary">.claude/settings.json</span>
<button class="px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity flex items-center gap-1"
onclick="openHookCreateModal()">
<span>+</span> New Hook
</button>
</div>
<span class="text-sm text-muted-foreground">${projectHookCount} hooks configured</span>
</div>
${projectHookCount === 0 ? `
<div class="hook-empty-state bg-card border border-border rounded-lg p-6 text-center">
<div class="text-3xl mb-3">🪝</div>
<p class="text-muted-foreground">No hooks configured for this project</p>
<p class="text-sm text-muted-foreground mt-1">Create a hook to automate actions on tool usage</p>
</div>
` : `
<div class="hook-grid grid gap-3">
${renderHooksByEvent(projectHooks, 'project')}
</div>
`}
</div>
<!-- Global Hooks -->
<div class="hook-section mb-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<h3 class="text-lg font-semibold text-foreground">Global Hooks</h3>
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-muted text-muted-foreground">~/.claude/settings.json</span>
</div>
<span class="text-sm text-muted-foreground">${globalHookCount} hooks configured</span>
</div>
${globalHookCount === 0 ? `
<div class="hook-empty-state bg-card border border-border rounded-lg p-6 text-center">
<p class="text-muted-foreground">No global hooks configured</p>
<p class="text-sm text-muted-foreground mt-1">Global hooks apply to all Claude Code sessions</p>
</div>
` : `
<div class="hook-grid grid gap-3">
${renderHooksByEvent(globalHooks, 'global')}
</div>
`}
</div>
<!-- Quick Install Templates -->
<div class="hook-section">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-foreground">Quick Install Templates</h3>
<span class="text-sm text-muted-foreground">One-click hook installation</span>
</div>
<div class="hook-templates-grid grid grid-cols-1 md:grid-cols-2 gap-4">
${renderQuickInstallCard('ccw-notify', 'CCW Dashboard Notify', 'Notify CCW dashboard when files are written', 'PostToolUse', 'Write')}
${renderQuickInstallCard('log-tool', 'Tool Usage Logger', 'Log all tool executions to a file', 'PostToolUse', 'All')}
${renderQuickInstallCard('lint-check', 'Auto Lint Check', 'Run ESLint on JavaScript/TypeScript files after write', 'PostToolUse', 'Write')}
${renderQuickInstallCard('git-add', 'Auto Git Stage', 'Automatically stage written files to git', 'PostToolUse', 'Write')}
</div>
</div>
<!-- Hook Environment Variables Reference -->
<div class="hook-section mt-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-foreground">Environment Variables Reference</h3>
</div>
<div class="bg-card border border-border rounded-lg p-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div class="space-y-2">
<div class="flex items-start gap-2">
<code class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">$CLAUDE_FILE_PATHS</code>
<span class="text-muted-foreground">Space-separated file paths affected</span>
</div>
<div class="flex items-start gap-2">
<code class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">$CLAUDE_TOOL_NAME</code>
<span class="text-muted-foreground">Name of the tool being executed</span>
</div>
<div class="flex items-start gap-2">
<code class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">$CLAUDE_TOOL_INPUT</code>
<span class="text-muted-foreground">JSON input passed to the tool</span>
</div>
</div>
<div class="space-y-2">
<div class="flex items-start gap-2">
<code class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">$CLAUDE_SESSION_ID</code>
<span class="text-muted-foreground">Current Claude session ID</span>
</div>
<div class="flex items-start gap-2">
<code class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">$CLAUDE_PROJECT_DIR</code>
<span class="text-muted-foreground">Current project directory path</span>
</div>
<div class="flex items-start gap-2">
<code class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">$CLAUDE_WORKING_DIR</code>
<span class="text-muted-foreground">Current working directory</span>
</div>
</div>
</div>
</div>
</div>
</div>
`;
// Attach event listeners
attachHookEventListeners();
}
function countHooks(hooks) {
let count = 0;
for (const event of Object.keys(hooks)) {
const hookList = hooks[event];
count += Array.isArray(hookList) ? hookList.length : 1;
}
return count;
}
function renderHooksByEvent(hooks, scope) {
const events = Object.keys(hooks);
if (events.length === 0) return '';
return events.map(event => {
const hookList = Array.isArray(hooks[event]) ? hooks[event] : [hooks[event]];
return hookList.map((hook, index) => {
const matcher = hook.matcher || 'All tools';
const command = hook.command || 'N/A';
const args = hook.args || [];
return `
<div class="hook-card bg-card border border-border rounded-lg p-4 hover:shadow-md transition-all">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<span class="text-xl">${getHookEventIcon(event)}</span>
<div>
<h4 class="font-semibold text-foreground">${event}</h4>
<p class="text-xs text-muted-foreground">${getHookEventDescription(event)}</p>
</div>
</div>
<div class="flex items-center gap-2">
<button class="p-1.5 text-muted-foreground hover:text-foreground hover:bg-hover rounded transition-colors"
data-scope="${scope}"
data-event="${event}"
data-index="${index}"
data-action="edit"
title="Edit hook">
✏️
</button>
<button class="p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
data-scope="${scope}"
data-event="${event}"
data-index="${index}"
data-action="delete"
title="Delete hook">
🗑️
</button>
</div>
</div>
<div class="hook-details text-sm space-y-2">
<div class="flex items-center gap-2">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">matcher</span>
<span class="text-muted-foreground">${escapeHtml(matcher)}</span>
</div>
<div class="flex items-center gap-2">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">command</span>
<span class="font-mono text-xs text-foreground">${escapeHtml(command)}</span>
</div>
${args.length > 0 ? `
<div class="flex items-start gap-2">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">args</span>
<span class="font-mono text-xs text-muted-foreground truncate" title="${escapeHtml(args.join(' '))}">${escapeHtml(args.slice(0, 3).join(' '))}${args.length > 3 ? '...' : ''}</span>
</div>
` : ''}
</div>
</div>
`;
}).join('');
}).join('');
}
function renderQuickInstallCard(templateId, title, description, event, matcher) {
const isInstalled = isHookTemplateInstalled(templateId);
return `
<div class="hook-template-card bg-card border border-border rounded-lg p-4 hover:shadow-md transition-all ${isInstalled ? 'border-success bg-success-light/30' : ''}">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<span class="text-xl">${isInstalled ? '✅' : '🪝'}</span>
<div>
<h4 class="font-semibold text-foreground">${escapeHtml(title)}</h4>
<p class="text-xs text-muted-foreground">${escapeHtml(description)}</p>
</div>
</div>
</div>
<div class="hook-template-meta text-xs text-muted-foreground mb-3 flex items-center gap-3">
<span class="flex items-center gap-1">
<span class="font-mono bg-muted px-1 py-0.5 rounded">${event}</span>
</span>
<span class="flex items-center gap-1">
Matches: <span class="font-medium">${matcher}</span>
</span>
</div>
<div class="flex items-center gap-2">
${isInstalled ? `
<button class="flex-1 px-3 py-1.5 text-sm bg-destructive/10 text-destructive rounded hover:bg-destructive/20 transition-colors"
data-template="${templateId}"
data-action="uninstall">
Uninstall
</button>
` : `
<button class="flex-1 px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
data-template="${templateId}"
data-action="install-project">
Install (Project)
</button>
<button class="px-3 py-1.5 text-sm bg-muted text-foreground rounded hover:bg-hover transition-colors"
data-template="${templateId}"
data-action="install-global">
Global
</button>
`}
</div>
</div>
`;
}
function isHookTemplateInstalled(templateId) {
const template = HOOK_TEMPLATES[templateId];
if (!template) return false;
// Check project hooks
const projectHooks = hookConfig.project?.hooks?.[template.event];
if (projectHooks) {
const hookList = Array.isArray(projectHooks) ? projectHooks : [projectHooks];
if (hookList.some(h => h.command === template.command)) return true;
}
// Check global hooks
const globalHooks = hookConfig.global?.hooks?.[template.event];
if (globalHooks) {
const hookList = Array.isArray(globalHooks) ? globalHooks : [globalHooks];
if (hookList.some(h => h.command === template.command)) return true;
}
return false;
}
async function installHookTemplate(templateId, scope) {
const template = HOOK_TEMPLATES[templateId];
if (!template) {
showRefreshToast('Template not found', 'error');
return;
}
const hookData = {
command: template.command,
args: template.args
};
if (template.matcher) {
hookData.matcher = template.matcher;
}
await saveHook(scope, template.event, hookData);
}
async function uninstallHookTemplate(templateId) {
const template = HOOK_TEMPLATES[templateId];
if (!template) return;
// Find and remove from project hooks
const projectHooks = hookConfig.project?.hooks?.[template.event];
if (projectHooks) {
const hookList = Array.isArray(projectHooks) ? projectHooks : [projectHooks];
const index = hookList.findIndex(h => h.command === template.command);
if (index !== -1) {
await removeHook('project', template.event, index);
return;
}
}
// Find and remove from global hooks
const globalHooks = hookConfig.global?.hooks?.[template.event];
if (globalHooks) {
const hookList = Array.isArray(globalHooks) ? globalHooks : [globalHooks];
const index = hookList.findIndex(h => h.command === template.command);
if (index !== -1) {
await removeHook('global', template.event, index);
return;
}
}
}
function attachHookEventListeners() {
// Edit buttons
document.querySelectorAll('.hook-card button[data-action="edit"]').forEach(btn => {
btn.addEventListener('click', (e) => {
const scope = e.target.dataset.scope;
const event = e.target.dataset.event;
const index = parseInt(e.target.dataset.index);
const hooks = scope === 'global' ? hookConfig.global.hooks : hookConfig.project.hooks;
const hookList = Array.isArray(hooks[event]) ? hooks[event] : [hooks[event]];
const hook = hookList[index];
if (hook) {
openHookCreateModal({
scope: scope,
event: event,
index: index,
matcher: hook.matcher || '',
command: hook.command,
args: hook.args || []
});
}
});
});
// Delete buttons
document.querySelectorAll('.hook-card button[data-action="delete"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const scope = e.target.dataset.scope;
const event = e.target.dataset.event;
const index = parseInt(e.target.dataset.index);
if (confirm(`Remove this ${event} hook?`)) {
await removeHook(scope, event, index);
}
});
});
// Install project buttons
document.querySelectorAll('button[data-action="install-project"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const templateId = e.target.dataset.template;
await installHookTemplate(templateId, 'project');
});
});
// Install global buttons
document.querySelectorAll('button[data-action="install-global"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const templateId = e.target.dataset.template;
await installHookTemplate(templateId, 'global');
});
});
// Uninstall buttons
document.querySelectorAll('button[data-action="uninstall"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const templateId = e.target.dataset.template;
await uninstallHookTemplate(templateId);
});
});
}

View File

@@ -345,12 +345,19 @@ async function loadAndRenderLiteContextTab(session, contentArea) {
const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=context`);
if (response.ok) {
const data = await response.json();
contentArea.innerHTML = renderLiteContextContent(data.context, session);
contentArea.innerHTML = renderLiteContextContent(data.context, data.explorations, session);
// Re-initialize collapsible sections for explorations
setTimeout(() => {
document.querySelectorAll('.collapsible-header').forEach(header => {
header.addEventListener('click', () => toggleSection(header));
});
}, 50);
return;
}
}
// Fallback: show plan context if available
contentArea.innerHTML = renderLiteContextContent(null, session);
contentArea.innerHTML = renderLiteContextContent(null, null, session);
} catch (err) {
contentArea.innerHTML = `<div class="tab-error">Failed to load context: ${err.message}</div>`;
}

View File

@@ -0,0 +1,242 @@
// MCP Manager View
// Renders the MCP server management interface
async function renderMcpManager() {
const container = document.getElementById('mainContent');
if (!container) return;
// Hide stats grid and search for MCP view
const statsGrid = document.getElementById('statsGrid');
const searchInput = document.getElementById('searchInput');
if (statsGrid) statsGrid.style.display = 'none';
if (searchInput) searchInput.parentElement.style.display = 'none';
// Load MCP config if not already loaded
if (!mcpConfig) {
await loadMcpConfig();
}
const currentPath = projectPath.replace(/\//g, '\\');
const projectData = mcpAllProjects[currentPath] || {};
const projectServers = projectData.mcpServers || {};
const disabledServers = projectData.disabledMcpServers || [];
// Get all available servers from all projects
const allAvailableServers = getAllAvailableMcpServers();
// Separate current project servers and available servers
const currentProjectServerNames = Object.keys(projectServers);
const otherAvailableServers = Object.entries(allAvailableServers)
.filter(([name]) => !currentProjectServerNames.includes(name));
container.innerHTML = `
<div class="mcp-manager">
<!-- Current Project MCP Servers -->
<div class="mcp-section mb-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<h3 class="text-lg font-semibold text-foreground">Current Project MCP Servers</h3>
<button class="px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity flex items-center gap-1"
onclick="openMcpCreateModal()">
<span>+</span> New Server
</button>
</div>
<span class="text-sm text-muted-foreground">${currentProjectServerNames.length} servers configured</span>
</div>
${currentProjectServerNames.length === 0 ? `
<div class="mcp-empty-state bg-card border border-border rounded-lg p-6 text-center">
<div class="text-3xl mb-3">🔌</div>
<p class="text-muted-foreground">No MCP servers configured for this project</p>
<p class="text-sm text-muted-foreground mt-1">Add servers from the available list below</p>
</div>
` : `
<div class="mcp-server-grid grid gap-3">
${currentProjectServerNames.map(serverName => {
const serverConfig = projectServers[serverName];
const isEnabled = !disabledServers.includes(serverName);
return renderMcpServerCard(serverName, serverConfig, isEnabled, true);
}).join('')}
</div>
`}
</div>
<!-- Available MCP Servers from Other Projects -->
<div class="mcp-section">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-foreground">Available from Other Projects</h3>
<span class="text-sm text-muted-foreground">${otherAvailableServers.length} servers available</span>
</div>
${otherAvailableServers.length === 0 ? `
<div class="mcp-empty-state bg-card border border-border rounded-lg p-6 text-center">
<p class="text-muted-foreground">No additional MCP servers found in other projects</p>
</div>
` : `
<div class="mcp-server-grid grid gap-3">
${otherAvailableServers.map(([serverName, serverInfo]) => {
return renderAvailableServerCard(serverName, serverInfo);
}).join('')}
</div>
`}
</div>
<!-- All Projects Overview -->
<div class="mcp-section mt-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-foreground">All Projects</h3>
<span class="text-sm text-muted-foreground">${Object.keys(mcpAllProjects).length} projects</span>
</div>
<div class="mcp-projects-list bg-card border border-border rounded-lg overflow-hidden">
${Object.entries(mcpAllProjects).map(([path, config]) => {
const servers = config.mcpServers || {};
const serverCount = Object.keys(servers).length;
const isCurrentProject = path === currentPath;
return `
<div class="mcp-project-item flex items-center justify-between px-4 py-3 border-b border-border last:border-b-0 hover:bg-hover cursor-pointer ${isCurrentProject ? 'bg-primary-light' : ''}"
onclick="switchToProject('${escapeHtml(path)}')"
data-project-path="${escapeHtml(path)}">
<div class="flex items-center gap-3 min-w-0">
<span class="text-lg">${isCurrentProject ? '📍' : '📁'}</span>
<div class="min-w-0">
<div class="font-medium text-foreground truncate" title="${escapeHtml(path)}">${escapeHtml(path.split('\\').pop() || path)}</div>
<div class="text-xs text-muted-foreground truncate">${escapeHtml(path)}</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full ${serverCount > 0 ? 'bg-success-light text-success' : 'bg-hover text-muted-foreground'}">${serverCount} MCP</span>
${isCurrentProject ? '<span class="text-xs text-primary font-medium">Current</span>' : ''}
</div>
</div>
`;
}).join('')}
</div>
</div>
</div>
`;
// Attach event listeners for toggle switches
attachMcpEventListeners();
}
function renderMcpServerCard(serverName, serverConfig, isEnabled, isInCurrentProject) {
const command = serverConfig.command || 'N/A';
const args = serverConfig.args || [];
const hasEnv = serverConfig.env && Object.keys(serverConfig.env).length > 0;
return `
<div class="mcp-server-card bg-card border border-border rounded-lg p-4 hover:shadow-md transition-all ${isEnabled ? '' : 'opacity-60'}">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<span class="text-xl">${isEnabled ? '🟢' : '🔴'}</span>
<h4 class="font-semibold text-foreground">${escapeHtml(serverName)}</h4>
</div>
<label class="mcp-toggle relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer"
${isEnabled ? 'checked' : ''}
data-server-name="${escapeHtml(serverName)}"
data-action="toggle">
<div class="w-9 h-5 bg-hover peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-success"></div>
</label>
</div>
<div class="mcp-server-details text-sm space-y-1">
<div class="flex items-center gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">cmd</span>
<span class="truncate" title="${escapeHtml(command)}">${escapeHtml(command)}</span>
</div>
${args.length > 0 ? `
<div class="flex items-start gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">args</span>
<span class="text-xs font-mono truncate" title="${escapeHtml(args.join(' '))}">${escapeHtml(args.slice(0, 3).join(' '))}${args.length > 3 ? '...' : ''}</span>
</div>
` : ''}
${hasEnv ? `
<div class="flex items-center gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">env</span>
<span class="text-xs">${Object.keys(serverConfig.env).length} variables</span>
</div>
` : ''}
</div>
${isInCurrentProject ? `
<div class="mt-3 pt-3 border-t border-border">
<button class="text-xs text-destructive hover:text-destructive/80 transition-colors"
data-server-name="${escapeHtml(serverName)}"
data-action="remove">
Remove from project
</button>
</div>
` : ''}
</div>
`;
}
function renderAvailableServerCard(serverName, serverInfo) {
const serverConfig = serverInfo.config;
const usedIn = serverInfo.usedIn || [];
const command = serverConfig.command || 'N/A';
return `
<div class="mcp-server-card mcp-server-available bg-card border border-border border-dashed rounded-lg p-4 hover:shadow-md hover:border-solid transition-all">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<span class="text-xl">⚪</span>
<h4 class="font-semibold text-foreground">${escapeHtml(serverName)}</h4>
</div>
<button class="px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
data-server-name="${escapeHtml(serverName)}"
data-server-config='${JSON.stringify(serverConfig).replace(/'/g, "&#39;")}'
data-action="add">
Add
</button>
</div>
<div class="mcp-server-details text-sm space-y-1">
<div class="flex items-center gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">cmd</span>
<span class="truncate" title="${escapeHtml(command)}">${escapeHtml(command)}</span>
</div>
<div class="flex items-center gap-2 text-muted-foreground">
<span class="text-xs">Used in ${usedIn.length} project${usedIn.length !== 1 ? 's' : ''}</span>
</div>
</div>
</div>
`;
}
function attachMcpEventListeners() {
// Toggle switches
document.querySelectorAll('.mcp-server-card input[data-action="toggle"]').forEach(input => {
input.addEventListener('change', async (e) => {
const serverName = e.target.dataset.serverName;
const enable = e.target.checked;
await toggleMcpServer(serverName, enable);
});
});
// Add buttons
document.querySelectorAll('.mcp-server-card button[data-action="add"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const serverName = e.target.dataset.serverName;
const serverConfig = JSON.parse(e.target.dataset.serverConfig);
await copyMcpServerToProject(serverName, serverConfig);
});
});
// Remove buttons
document.querySelectorAll('.mcp-server-card button[data-action="remove"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const serverName = e.target.dataset.serverName;
if (confirm(`Remove MCP server "${serverName}" from this project?`)) {
await removeMcpServerFromProject(serverName);
}
});
});
}
function switchToProject(path) {
// Use existing path selection mechanism
selectPath(path.replace(/\\\\/g, '\\'));
}

View File

@@ -3,6 +3,9 @@
// ==========================================
function renderProjectOverview() {
// Show stats grid and search (may be hidden by MCP view)
if (typeof showStatsAndSearch === 'function') showStatsAndSearch();
const container = document.getElementById('mainContent');
const project = workflowData.projectOverview;

View File

@@ -5658,3 +5658,588 @@ code.ctx-meta-chip-value {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* ==========================================
MCP MANAGER STYLES
========================================== */
.mcp-manager {
width: 100%;
}
.mcp-section {
margin-bottom: 2rem;
width: 100%;
}
.mcp-server-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
width: 100%;
}
.mcp-server-card {
position: relative;
}
.mcp-server-card.opacity-60 {
opacity: 0.6;
}
.mcp-server-available {
border-style: dashed;
}
.mcp-server-available:hover {
border-style: solid;
}
/* MCP Toggle Switch */
.mcp-toggle {
position: relative;
display: inline-flex;
align-items: center;
}
.mcp-toggle input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.mcp-toggle > div {
width: 36px;
height: 20px;
background: hsl(var(--muted));
border-radius: 10px;
position: relative;
transition: background 0.2s;
}
.mcp-toggle > div::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
background: white;
border-radius: 50%;
transition: transform 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.mcp-toggle input:checked + div {
background: hsl(var(--success));
}
.mcp-toggle input:checked + div::after {
transform: translateX(16px);
}
.mcp-toggle input:focus + div {
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.2);
}
/* MCP Projects List */
.mcp-projects-list {
max-height: 400px;
overflow-y: auto;
}
.mcp-project-item {
transition: background 0.15s;
}
.mcp-project-item:hover {
background: hsl(var(--hover));
}
.mcp-project-item.bg-primary-light {
background: hsl(var(--primary-light));
}
/* MCP Empty State */
.mcp-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 120px;
}
/* MCP Server Details */
.mcp-server-details {
font-size: 0.875rem;
}
.mcp-server-details .font-mono {
font-family: var(--font-mono);
}
/* MCP Create Modal */
.mcp-modal {
animation: fadeIn 0.15s ease-out;
}
.mcp-modal-backdrop {
animation: fadeIn 0.15s ease-out;
}
.mcp-modal-content {
animation: slideUp 0.2s ease-out;
}
.mcp-modal.hidden {
display: none;
}
.mcp-modal .form-group label {
display: block;
margin-bottom: 0.25rem;
}
.mcp-modal input,
.mcp-modal textarea {
transition: border-color 0.15s, box-shadow 0.15s;
}
.mcp-modal input:focus,
.mcp-modal textarea:focus {
border-color: hsl(var(--primary));
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.2);
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* ==========================================
HOOK MANAGER STYLES
========================================== */
.hook-manager {
width: 100%;
}
.hook-section {
margin-bottom: 2rem;
width: 100%;
}
.hook-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
width: 100%;
}
.hook-card {
position: relative;
}
.hook-details {
font-size: 0.875rem;
}
.hook-details .font-mono {
font-family: var(--font-mono);
}
.hook-templates-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.hook-template-card {
transition: all 0.2s ease;
}
.hook-template-card:hover {
box-shadow: 0 4px 12px rgb(0 0 0 / 0.1);
}
/* Hook Empty State */
.hook-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 120px;
}
/* Hook Modal */
.hook-modal {
animation: fadeIn 0.15s ease-out;
}
.hook-modal-backdrop {
animation: fadeIn 0.15s ease-out;
}
.hook-modal-content {
animation: slideUp 0.2s ease-out;
}
.hook-modal.hidden {
display: none;
}
.hook-modal .form-group label {
display: block;
margin-bottom: 0.25rem;
}
.hook-modal input,
.hook-modal textarea,
.hook-modal select {
transition: border-color 0.15s, box-shadow 0.15s;
}
.hook-modal input:focus,
.hook-modal textarea:focus,
.hook-modal select:focus {
border-color: hsl(var(--primary));
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.2);
}
.hook-template-btn {
transition: all 0.15s ease;
}
.hook-template-btn:hover {
background: hsl(var(--hover));
border-color: hsl(var(--primary));
}
/* ==========================================
STATS SECTION & CAROUSEL
========================================== */
.stats-section {
min-height: 180px;
}
.stats-metrics {
width: 300px;
}
.stats-carousel {
position: relative;
}
.carousel-header {
height: 40px;
}
.carousel-btn {
transition: all 0.15s;
}
.carousel-btn:hover {
background: hsl(var(--hover));
}
/* Carousel dots indicator */
.carousel-dots {
display: flex;
align-items: center;
gap: 6px;
}
.carousel-dot {
cursor: pointer;
border: none;
padding: 0;
transition: all 0.2s ease;
}
.carousel-dot:focus {
outline: none;
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.3);
}
.carousel-footer {
flex-shrink: 0;
}
.carousel-content {
position: relative;
overflow: hidden;
}
.carousel-slide {
position: absolute;
inset: 0;
}
.carousel-empty {
position: absolute;
inset: 0;
}
/* Carousel slide animations */
.carousel-fade-in {
animation: carouselFadeIn 0.3s ease-out forwards;
}
.carousel-slide-left {
animation: carouselSlideLeft 0.35s ease-out forwards;
}
.carousel-slide-right {
animation: carouselSlideRight 0.35s ease-out forwards;
}
@keyframes carouselFadeIn {
from {
opacity: 0;
transform: scale(0.98);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes carouselSlideLeft {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes carouselSlideRight {
from {
opacity: 0;
transform: translateX(-100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Task card in carousel */
.carousel-slide .task-card {
height: 100%;
display: flex;
flex-direction: column;
}
.carousel-slide .task-timestamps {
flex-grow: 1;
}
.carousel-slide .task-session-info {
margin-top: auto;
}
/* Task status badge pulse for in_progress */
.task-status-badge {
transition: all 0.2s;
}
.bg-warning-light .task-status-badge {
animation: statusPulse 2s ease-in-out infinite;
}
@keyframes statusPulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
/* Task highlight animation when navigating from carousel */
.task-highlight {
animation: taskHighlight 0.5s ease-out 3;
}
@keyframes taskHighlight {
0%, 100% {
background: transparent;
box-shadow: none;
}
50% {
background: hsl(var(--primary-light));
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.3);
}
}
/* Line clamp utility */
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Highlight pulse effect */
.highlight-pulse {
animation: highlightPulse 0.5s ease-out 2;
}
@keyframes highlightPulse {
0%, 100% {
box-shadow: none;
}
50% {
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.3);
}
}
/* ==========================================
NOTIFICATION BUBBLES
========================================== */
.notification-bubble {
position: fixed;
top: 70px;
right: 20px;
max-width: 360px;
padding: 12px 16px;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 8px;
box-shadow: 0 8px 24px rgb(0 0 0 / 0.15);
z-index: 1000;
display: flex;
align-items: center;
gap: 8px;
opacity: 0;
transform: translateX(100%);
transition: all 0.3s ease-out;
}
.notification-bubble.show {
opacity: 1;
transform: translateX(0);
}
.notification-bubble.fade-out {
opacity: 0;
transform: translateX(100%);
}
.notification-bubble:nth-child(2) {
top: 130px;
}
.notification-bubble:nth-child(3) {
top: 190px;
}
.notification-content {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.notification-icon {
font-size: 1.25rem;
}
.notification-message {
font-size: 0.875rem;
color: hsl(var(--foreground));
flex: 1;
}
.notification-action {
padding: 4px 12px;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
}
.notification-action:hover {
opacity: 0.9;
}
.notification-close {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: hsl(var(--muted-foreground));
font-size: 1.25rem;
cursor: pointer;
border-radius: 4px;
transition: all 0.15s;
}
.notification-close:hover {
background: hsl(var(--hover));
color: hsl(var(--foreground));
}
/* Notification types */
.notification-success {
border-left: 3px solid hsl(var(--success));
}
.notification-warning {
border-left: 3px solid hsl(var(--warning));
}
.notification-error {
border-left: 3px solid hsl(var(--destructive));
}
.notification-info {
border-left: 3px solid hsl(var(--primary));
}
/* Responsive stats section */
@media (max-width: 768px) {
.stats-section {
flex-direction: column;
}
.stats-metrics {
width: 100%;
grid-template-columns: repeat(4, 1fr);
}
.stats-carousel {
min-height: 160px;
}
}

View File

@@ -310,6 +310,36 @@
</li>
</ul>
</div>
<!-- MCP Servers Section -->
<div class="mb-2" id="mcpServersNav">
<div class="flex items-center px-4 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
<span class="mr-2">🔌</span>
<span class="nav-section-title">MCP Servers</span>
</div>
<ul class="space-y-0.5">
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-view="mcp-manager" data-tooltip="MCP Server Management">
<span>⚙️</span>
<span class="nav-text flex-1">Manage</span>
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-hover text-muted-foreground" id="badgeMcpServers">0</span>
</li>
</ul>
</div>
<!-- Hooks Section -->
<div class="mb-2" id="hooksNav">
<div class="flex items-center px-4 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
<span class="mr-2">🪝</span>
<span class="nav-section-title">Hooks</span>
</div>
<ul class="space-y-0.5">
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-view="hook-manager" data-tooltip="Hook Management">
<span>⚙️</span>
<span class="nav-text flex-1">Manage</span>
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-hover text-muted-foreground" id="badgeHooks">0</span>
</li>
</ul>
</div>
</nav>
<!-- Sidebar Footer -->
@@ -323,27 +353,67 @@
<!-- Content Area -->
<main class="flex-1 p-6 overflow-y-auto min-w-0">
<!-- Stats Grid -->
<section class="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-4 mb-6">
<div class="bg-card border border-border rounded-lg p-5 text-center hover:shadow-md transition-all duration-200">
<div class="text-2xl mb-2">📊</div>
<div class="text-3xl font-bold text-foreground" id="statTotalSessions">0</div>
<div class="text-sm text-muted-foreground mt-1">Total Sessions</div>
<!-- Stats Section: Left Metrics + Right Carousel -->
<section id="statsGrid" class="stats-section flex gap-4 mb-6">
<!-- Left: 4 Metrics Grid -->
<div class="stats-metrics grid grid-cols-2 gap-3 shrink-0">
<div class="bg-card border border-border rounded-lg p-4 text-center hover:shadow-md transition-all duration-200 min-w-[140px]">
<div class="text-xl mb-1">📊</div>
<div class="text-2xl font-bold text-foreground" id="statTotalSessions">0</div>
<div class="text-xs text-muted-foreground mt-1">Total Sessions</div>
</div>
<div class="bg-card border border-border rounded-lg p-4 text-center hover:shadow-md transition-all duration-200 min-w-[140px]">
<div class="text-xl mb-1">🟢</div>
<div class="text-2xl font-bold text-foreground" id="statActiveSessions">0</div>
<div class="text-xs text-muted-foreground mt-1">Active Sessions</div>
</div>
<div class="bg-card border border-border rounded-lg p-4 text-center hover:shadow-md transition-all duration-200 min-w-[140px]">
<div class="text-xl mb-1">📋</div>
<div class="text-2xl font-bold text-foreground" id="statTotalTasks">0</div>
<div class="text-xs text-muted-foreground mt-1">Total Tasks</div>
</div>
<div class="bg-card border border-border rounded-lg p-4 text-center hover:shadow-md transition-all duration-200 min-w-[140px]">
<div class="text-xl mb-1"></div>
<div class="text-2xl font-bold text-foreground" id="statCompletedTasks">0</div>
<div class="text-xs text-muted-foreground mt-1">Completed Tasks</div>
</div>
</div>
<div class="bg-card border border-border rounded-lg p-5 text-center hover:shadow-md transition-all duration-200">
<div class="text-2xl mb-2">🟢</div>
<div class="text-3xl font-bold text-foreground" id="statActiveSessions">0</div>
<div class="text-sm text-muted-foreground mt-1">Active Sessions</div>
</div>
<div class="bg-card border border-border rounded-lg p-5 text-center hover:shadow-md transition-all duration-200">
<div class="text-2xl mb-2">📋</div>
<div class="text-3xl font-bold text-foreground" id="statTotalTasks">0</div>
<div class="text-sm text-muted-foreground mt-1">Total Tasks</div>
</div>
<div class="bg-card border border-border rounded-lg p-5 text-center hover:shadow-md transition-all duration-200">
<div class="text-2xl mb-2"></div>
<div class="text-3xl font-bold text-foreground" id="statCompletedTasks">0</div>
<div class="text-sm text-muted-foreground mt-1">Completed Tasks</div>
<!-- Right: Active Session Carousel (Image-style with dots) -->
<div class="stats-carousel flex-1 bg-card border border-border rounded-lg overflow-hidden min-h-[180px] flex flex-col relative">
<!-- Carousel Content (Full height) -->
<div class="carousel-content flex-1 relative overflow-hidden" id="carouselContent">
<!-- Dynamic carousel slides -->
<div class="carousel-empty flex items-center justify-center h-full text-muted-foreground">
<div class="text-center">
<div class="text-3xl mb-2">🎯</div>
<p class="text-sm">No active sessions</p>
</div>
</div>
</div>
<!-- Bottom: Dots Indicator & Controls -->
<div class="carousel-footer flex items-center justify-center gap-3 py-2 border-t border-border bg-muted/20">
<!-- Previous Button -->
<button class="carousel-btn p-1 rounded hover:bg-hover text-muted-foreground hover:text-foreground" id="carouselPrev" title="Previous">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
</button>
<!-- Dots Indicator -->
<div class="carousel-dots flex items-center gap-1.5" id="carouselDots">
<!-- Dots will be rendered dynamically -->
</div>
<!-- Next Button -->
<button class="carousel-btn p-1 rounded hover:bg-hover text-muted-foreground hover:text-foreground" id="carouselNext" title="Next">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
</button>
<!-- Pause Button -->
<button class="carousel-btn p-1 rounded hover:bg-hover text-muted-foreground hover:text-foreground ml-1" id="carouselPause" title="Pause auto-play">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" id="carouselPauseIcon"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
</button>
</div>
</div>
</section>
@@ -404,6 +474,120 @@
</div>
</div>
<!-- MCP Server Create Modal -->
<div id="mcpCreateModal" class="mcp-modal hidden fixed inset-0 z-[100] flex items-center justify-center">
<div class="mcp-modal-backdrop absolute inset-0 bg-black/60" onclick="closeMcpCreateModal()"></div>
<div class="mcp-modal-content relative bg-card border border-border rounded-lg shadow-2xl w-[90vw] max-w-lg flex flex-col">
<div class="mcp-modal-header flex items-center justify-between px-4 py-3 border-b border-border">
<h3 class="text-lg font-semibold text-foreground">Create MCP Server</h3>
<button class="w-8 h-8 flex items-center justify-center text-xl text-muted-foreground hover:text-foreground hover:bg-hover rounded" onclick="closeMcpCreateModal()">&times;</button>
</div>
<div class="mcp-modal-body p-4 space-y-4">
<div class="form-group">
<label class="block text-sm font-medium text-foreground mb-1">Server Name <span class="text-destructive">*</span></label>
<input type="text" id="mcpServerName" placeholder="e.g., my-mcp-server"
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20">
</div>
<div class="form-group">
<label class="block text-sm font-medium text-foreground mb-1">Command <span class="text-destructive">*</span></label>
<input type="text" id="mcpServerCommand" placeholder="e.g., npx, uvx, node, python"
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20">
</div>
<div class="form-group">
<label class="block text-sm font-medium text-foreground mb-1">Arguments (one per line)</label>
<textarea id="mcpServerArgs" placeholder="e.g.,&#10;-y&#10;@smithery/cli@latest&#10;run&#10;exa" rows="4"
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm font-mono focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 resize-none"></textarea>
</div>
<div class="form-group">
<label class="block text-sm font-medium text-foreground mb-1">Environment Variables (KEY=VALUE per line)</label>
<textarea id="mcpServerEnv" placeholder="e.g.,&#10;API_KEY=your-api-key&#10;DEBUG=true" rows="3"
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm font-mono focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 resize-none"></textarea>
</div>
</div>
<div class="mcp-modal-footer flex justify-end gap-2 px-4 py-3 border-t border-border">
<button class="px-4 py-2 text-sm bg-muted text-foreground rounded-lg hover:bg-hover transition-colors" onclick="closeMcpCreateModal()">Cancel</button>
<button class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity" onclick="submitMcpCreate()">Create</button>
</div>
</div>
</div>
<!-- Hook Create Modal -->
<div id="hookCreateModal" class="hook-modal hidden fixed inset-0 z-[100] flex items-center justify-center">
<div class="hook-modal-backdrop absolute inset-0 bg-black/60" onclick="closeHookCreateModal()"></div>
<div class="hook-modal-content relative bg-card border border-border rounded-lg shadow-2xl w-[90vw] max-w-lg flex flex-col max-h-[90vh]">
<div class="hook-modal-header flex items-center justify-between px-4 py-3 border-b border-border">
<h3 class="text-lg font-semibold text-foreground" id="hookModalTitle">Create Hook</h3>
<button class="w-8 h-8 flex items-center justify-center text-xl text-muted-foreground hover:text-foreground hover:bg-hover rounded" onclick="closeHookCreateModal()">&times;</button>
</div>
<div class="hook-modal-body p-4 space-y-4 overflow-y-auto">
<div class="form-group">
<label class="block text-sm font-medium text-foreground mb-1">Hook Event <span class="text-destructive">*</span></label>
<select id="hookEvent" class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20">
<option value="">Select an event...</option>
<option value="PreToolUse">PreToolUse - Before a tool is executed</option>
<option value="PostToolUse">PostToolUse - After a tool completes</option>
<option value="Notification">Notification - On notifications</option>
<option value="Stop">Stop - When agent stops</option>
</select>
</div>
<div class="form-group">
<label class="block text-sm font-medium text-foreground mb-1">Matcher (optional)</label>
<input type="text" id="hookMatcher" placeholder="e.g., Write, Edit, Bash (leave empty for all)"
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20">
<p class="text-xs text-muted-foreground mt-1">Tool name to match. Leave empty to match all tools.</p>
</div>
<div class="form-group">
<label class="block text-sm font-medium text-foreground mb-1">Command <span class="text-destructive">*</span></label>
<input type="text" id="hookCommand" placeholder="e.g., curl, bash, node"
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20">
</div>
<div class="form-group">
<label class="block text-sm font-medium text-foreground mb-1">Arguments (one per line)</label>
<textarea id="hookArgs" placeholder="e.g.,&#10;-X&#10;POST&#10;http://localhost:3456/api/hook" rows="4"
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm font-mono focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 resize-none"></textarea>
</div>
<div class="form-group">
<label class="block text-sm font-medium text-foreground mb-1">Scope</label>
<div class="flex gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="hookScope" value="project" checked class="text-primary focus:ring-primary">
<span class="text-sm text-foreground">Project (.claude/settings.json)</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="hookScope" value="global" class="text-primary focus:ring-primary">
<span class="text-sm text-foreground">Global (~/.claude/settings.json)</span>
</label>
</div>
</div>
<div class="form-group">
<label class="block text-sm font-medium text-foreground mb-2">Quick Templates</label>
<div class="grid grid-cols-2 gap-2">
<button class="hook-template-btn px-3 py-2 text-xs bg-muted text-foreground rounded border border-border hover:bg-hover transition-colors text-left" onclick="applyHookTemplate('ccw-notify')">
<span class="font-medium">CCW Notify</span>
<span class="block text-muted-foreground">Notify dashboard on Write</span>
</button>
<button class="hook-template-btn px-3 py-2 text-xs bg-muted text-foreground rounded border border-border hover:bg-hover transition-colors text-left" onclick="applyHookTemplate('log-tool')">
<span class="font-medium">Log Tool Usage</span>
<span class="block text-muted-foreground">Log all tool executions</span>
</button>
<button class="hook-template-btn px-3 py-2 text-xs bg-muted text-foreground rounded border border-border hover:bg-hover transition-colors text-left" onclick="applyHookTemplate('lint-check')">
<span class="font-medium">Lint Check</span>
<span class="block text-muted-foreground">Run eslint on file changes</span>
</button>
<button class="hook-template-btn px-3 py-2 text-xs bg-muted text-foreground rounded border border-border hover:bg-hover transition-colors text-left" onclick="applyHookTemplate('git-add')">
<span class="font-medium">Git Add</span>
<span class="block text-muted-foreground">Auto stage written files</span>
</button>
</div>
</div>
</div>
<div class="hook-modal-footer flex justify-end gap-2 px-4 py-3 border-t border-border">
<button class="px-4 py-2 text-sm bg-muted text-foreground rounded-lg hover:bg-hover transition-colors" onclick="closeHookCreateModal()">Cancel</button>
<button class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity" onclick="submitHookCreate()">Create</button>
</div>
</div>
</div>
<!-- D3.js for Flowchart -->
<script src="https://d3js.org/d3.v7.min.js"></script>
<!-- Marked.js for Markdown rendering -->