Files
Claude-Code-Workflow/ccw/src/templates/dashboard-js/components/notifications.js
catlog22 0157e36344 feat: add CLI Stream Viewer component for real-time output monitoring
- Implemented a new CLI Stream Viewer to display real-time output from CLI executions.
- Added state management for CLI executions, including handling of start, output, completion, and errors.
- Introduced UI rendering for stream tabs and content, with auto-scroll functionality.
- Integrated keyboard shortcuts for toggling the viewer and handling user interactions.

feat: create Issue Manager view for managing issues and execution queue

- Developed the Issue Manager view to manage issues, solutions, and execution queue.
- Implemented data loading functions for fetching issues and queue data from the API.
- Added filtering and rendering logic for issues and queue items, including drag-and-drop functionality.
- Created detail panel for viewing and editing issue details, including tasks and solutions.
2025-12-27 09:46:12 +08:00

775 lines
22 KiB
JavaScript

// ==========================================
// NOTIFICATIONS COMPONENT
// ==========================================
// Real-time silent refresh (no notification bubbles)
/**
* Format JSON object for display in notifications
* Parses JSON strings and formats objects into readable key-value pairs
* @param {Object|string} obj - Object or JSON string to format
* @param {number} maxLen - Max string length (unused, kept for compatibility)
* @returns {string} Formatted string with key: value pairs
*/
function formatJsonDetails(obj, maxLen = 150) {
// Handle null/undefined
if (obj === null || obj === undefined) return '';
// If it is a string, try to parse as JSON
if (typeof obj === 'string') {
// Check if it looks like JSON
const trimmed = obj.trim();
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'))) {
try {
obj = JSON.parse(trimmed);
} catch (e) {
// Not valid JSON, return as-is
return obj;
}
} else {
// Plain string, return as-is
return obj;
}
}
// Handle non-objects (numbers, booleans, etc.)
if (typeof obj !== 'object') return String(obj);
// Handle arrays
if (Array.isArray(obj)) {
if (obj.length === 0) return '(empty array)';
return obj.slice(0, 5).map((item, i) => {
const itemStr = typeof item === 'object' ? JSON.stringify(item) : String(item);
return `[${i}] ${itemStr.length > 50 ? itemStr.substring(0, 47) + '...' : itemStr}`;
}).join('\n') + (obj.length > 5 ? `\n... +${obj.length - 5} more` : '');
}
// Handle objects - format as readable key: value pairs
try {
const entries = Object.entries(obj);
if (entries.length === 0) return '(empty object)';
// Format each entry with proper value display
const lines = entries.slice(0, 8).map(([key, val]) => {
let valStr;
if (val === null) {
valStr = 'null';
} else if (val === undefined) {
valStr = 'undefined';
} else if (typeof val === 'boolean') {
valStr = val ? 'true' : 'false';
} else if (typeof val === 'number') {
valStr = String(val);
} else if (typeof val === 'object') {
valStr = JSON.stringify(val);
if (valStr.length > 40) valStr = valStr.substring(0, 37) + '...';
} else {
valStr = String(val);
if (valStr.length > 50) valStr = valStr.substring(0, 47) + '...';
}
return `${key}: ${valStr}`;
});
if (entries.length > 8) {
lines.push(`... +${entries.length - 8} more fields`);
}
return lines.join('\n');
} catch (e) {
// Fallback to stringified version
const str = JSON.stringify(obj);
return str.length > 200 ? str.substring(0, 197) + '...' : str;
}
}
let wsConnection = null;
let autoRefreshInterval = null;
let lastDataHash = null;
const AUTO_REFRESH_INTERVAL_MS = 30000; // 30 seconds
// Custom event handlers registry for components to subscribe to specific events
const wsEventHandlers = {};
/**
* Register a custom handler for a specific WebSocket event type
* @param {string} eventType - The event type to listen for
* @param {Function} handler - The handler function
*/
function registerWsEventHandler(eventType, handler) {
if (!wsEventHandlers[eventType]) {
wsEventHandlers[eventType] = [];
}
wsEventHandlers[eventType].push(handler);
}
/**
* Unregister a custom handler for a specific WebSocket event type
* @param {string} eventType - The event type
* @param {Function} handler - The handler function to remove
*/
function unregisterWsEventHandler(eventType, handler) {
if (wsEventHandlers[eventType]) {
wsEventHandlers[eventType] = wsEventHandlers[eventType].filter(h => h !== handler);
}
}
/**
* Dispatch event to registered handlers
* @param {string} eventType - The event type
* @param {Object} data - The full event data
*/
function dispatchToEventHandlers(eventType, data) {
if (wsEventHandlers[eventType]) {
wsEventHandlers[eventType].forEach(handler => {
try {
handler(data);
} catch (e) {
console.error('[WS] Error in custom handler for', eventType, e);
}
});
}
}
// ========== 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;
case 'SESSION_CREATED':
case 'SESSION_ARCHIVED':
case 'TASK_UPDATED':
case 'SESSION_UPDATED':
case 'TASK_CREATED':
case 'SUMMARY_WRITTEN':
case 'PLAN_UPDATED':
case 'REVIEW_UPDATED':
case 'CONTENT_WRITTEN':
case 'FILE_DELETED':
case 'DIRECTORY_CREATED':
// Route to state reducer for granular updates
if (typeof handleWorkflowEvent === 'function') {
handleWorkflowEvent({ type, ...payload });
} else {
// Fallback to full refresh if reducer not available
refreshIfNeeded();
}
break;
case 'tool_execution':
// Handle tool execution notifications from MCP tools
handleToolExecutionNotification(payload);
break;
case 'cli_execution':
// Handle CLI command notifications (ccw cli -p)
handleCliCommandNotification(payload);
break;
// CLI Tool Execution Events
case 'CLI_EXECUTION_STARTED':
if (typeof handleCliExecutionStarted === 'function') {
handleCliExecutionStarted(payload);
}
// Route to CLI Stream Viewer
if (typeof handleCliStreamStarted === 'function') {
handleCliStreamStarted(payload);
}
break;
case 'CLI_OUTPUT':
if (typeof handleCliOutput === 'function') {
handleCliOutput(payload);
}
// Route to CLI Stream Viewer
if (typeof handleCliStreamOutput === 'function') {
handleCliStreamOutput(payload);
}
break;
case 'CLI_EXECUTION_COMPLETED':
if (typeof handleCliExecutionCompleted === 'function') {
handleCliExecutionCompleted(payload);
}
// Route to CLI Stream Viewer
if (typeof handleCliStreamCompleted === 'function') {
handleCliStreamCompleted(payload);
}
break;
case 'CLI_EXECUTION_ERROR':
if (typeof handleCliExecutionError === 'function') {
handleCliExecutionError(payload);
}
// Route to CLI Stream Viewer
if (typeof handleCliStreamError === 'function') {
handleCliStreamError(payload);
}
break;
// CLI Review Events
case 'CLI_REVIEW_UPDATED':
if (typeof handleCliReviewUpdated === 'function') {
handleCliReviewUpdated(payload);
}
// Also refresh CLI history to show review status
if (typeof refreshCliHistory === 'function') {
refreshCliHistory();
}
break;
// System Notify Events (from CLI commands)
case 'REFRESH_REQUIRED':
handleRefreshRequired(payload);
break;
case 'MEMORY_UPDATED':
if (typeof handleMemoryUpdated === 'function') {
handleMemoryUpdated(payload);
}
// Force refresh of memory view
if (typeof loadMemoryStats === 'function') {
loadMemoryStats().then(function() {
if (typeof renderHotspotsColumn === 'function') renderHotspotsColumn();
}).catch(function(err) {
console.error('[Memory] Failed to refresh stats:', err);
});
}
break;
case 'HISTORY_UPDATED':
// Refresh CLI history when updated externally
if (typeof refreshCliHistory === 'function') {
refreshCliHistory();
}
break;
case 'INSIGHT_GENERATED':
// Refresh insights when new insight is generated
if (typeof loadInsightsHistory === 'function') {
loadInsightsHistory();
}
break;
case 'ACTIVE_MEMORY_SYNCED':
// Handle Active Memory sync completion
if (typeof addGlobalNotification === 'function') {
const { filesAnalyzed, tool, usedCli } = payload;
const method = usedCli ? `CLI (${tool})` : 'Basic';
addGlobalNotification(
'success',
'Active Memory synced',
{
'Files Analyzed': filesAnalyzed,
'Method': method,
'Timestamp': new Date(payload.timestamp).toLocaleTimeString()
},
'Memory'
);
}
// Refresh Active Memory status
if (typeof loadActiveMemoryStatus === 'function') {
loadActiveMemoryStatus().catch(function(err) {
console.error('[Active Memory] Failed to refresh status:', err);
});
}
console.log('[Active Memory] Sync completed:', payload);
break;
case 'CLAUDE_FILE_SYNCED':
// Handle CLAUDE.md file sync completion
if (typeof addGlobalNotification === 'function') {
const { path, level, tool, mode } = payload;
const fileName = path.split(/[/\\]/).pop();
addGlobalNotification(
'success',
`${fileName} synced`,
{
'Level': level,
'Tool': tool,
'Mode': mode,
'Time': new Date(payload.timestamp).toLocaleTimeString()
},
'CLAUDE.md'
);
}
// Refresh file list
if (typeof loadClaudeFiles === 'function') {
loadClaudeFiles().then(() => {
// Re-render the view to show updated content
if (typeof renderClaudeManager === 'function') {
renderClaudeManager();
}
}).catch(err => console.error('[CLAUDE.md] Failed to refresh files:', err));
}
console.log('[CLAUDE.md] Sync completed:', payload);
break;
case 'CLI_TOOL_INSTALLED':
// Handle CLI tool installation completion
if (typeof addGlobalNotification === 'function') {
const { tool } = payload;
addGlobalNotification(
'success',
`${tool} installed successfully`,
{
'Tool': tool,
'Time': new Date(payload.timestamp).toLocaleTimeString()
},
'CLI Tools'
);
}
// Refresh CLI manager
if (typeof loadCliToolStatus === 'function') {
loadCliToolStatus().then(() => {
if (typeof renderToolsSection === 'function') {
renderToolsSection();
}
}).catch(err => console.error('[CLI Tools] Failed to refresh status:', err));
}
console.log('[CLI Tools] Installation completed:', payload);
break;
case 'CLI_TOOL_UNINSTALLED':
// Handle CLI tool uninstallation completion
if (typeof addGlobalNotification === 'function') {
const { tool } = payload;
addGlobalNotification(
'success',
`${tool} uninstalled successfully`,
{
'Tool': tool,
'Time': new Date(payload.timestamp).toLocaleTimeString()
},
'CLI Tools'
);
}
// Refresh CLI manager
if (typeof loadCliToolStatus === 'function') {
loadCliToolStatus().then(() => {
if (typeof renderToolsSection === 'function') {
renderToolsSection();
}
}).catch(err => console.error('[CLI Tools] Failed to refresh status:', err));
}
console.log('[CLI Tools] Uninstallation completed:', payload);
break;
case 'CODEXLENS_INSTALLED':
// Handle CodexLens installation completion
if (typeof addGlobalNotification === 'function') {
const { version } = payload;
addGlobalNotification(
'success',
`CodexLens installed successfully`,
{
'Version': version || 'latest',
'Time': new Date(payload.timestamp).toLocaleTimeString()
},
'CodexLens'
);
}
// Refresh CLI status if active
if (typeof loadCodexLensStatus === 'function') {
loadCodexLensStatus().then(() => {
if (typeof renderCliStatus === 'function') {
renderCliStatus();
}
});
}
console.log('[CodexLens] Installation completed:', payload);
break;
case 'CODEXLENS_UNINSTALLED':
// Handle CodexLens uninstallation completion
if (typeof addGlobalNotification === 'function') {
addGlobalNotification(
'success',
`CodexLens uninstalled successfully`,
{
'Time': new Date(payload.timestamp).toLocaleTimeString()
},
'CodexLens'
);
}
// Refresh CLI status if active
if (typeof loadCodexLensStatus === 'function') {
loadCodexLensStatus().then(() => {
if (typeof renderCliStatus === 'function') {
renderCliStatus();
}
});
}
console.log('[CodexLens] Uninstallation completed:', payload);
break;
case 'CODEXLENS_INDEX_PROGRESS':
// Handle CodexLens index progress updates
dispatchToEventHandlers('CODEXLENS_INDEX_PROGRESS', data);
console.log('[CodexLens] Index progress:', payload.stage, payload.percent + '%');
break;
default:
console.log('[WS] Unknown notification type:', type);
}
}
/**
* Handle tool execution notifications from MCP tools
* @param {Object} payload - Tool execution payload
*/
function handleToolExecutionNotification(payload) {
const { toolName, status, params, result, error, timestamp } = payload;
// Determine notification type and message
let notifType = 'info';
let message = `Tool: ${toolName}`;
let details = null;
switch (status) {
case 'started':
notifType = 'info';
message = `Executing ${toolName}...`;
// Pass raw object for HTML formatting
if (params) {
details = params;
}
break;
case 'completed':
notifType = 'success';
message = `${toolName} completed`;
// Pass raw object for HTML formatting
if (result) {
if (result._truncated) {
details = result.preview;
} else {
details = result;
}
}
break;
case 'failed':
notifType = 'error';
message = `${toolName} failed`;
details = error || 'Unknown error';
break;
default:
notifType = 'info';
message = `${toolName}: ${status}`;
}
// Add to global notifications - pass objects directly for HTML formatting
if (typeof addGlobalNotification === 'function') {
addGlobalNotification(notifType, message, details, 'MCP');
}
// Log to console
console.log(`[MCP] ${status}: ${toolName}`, payload);
}
/**
* Handle CLI command notifications (ccw cli -p)
* @param {Object} payload - CLI execution payload
*/
function handleCliCommandNotification(payload) {
const { event, tool, mode, prompt_preview, execution_id, success, duration_ms, status, error, turn_count, custom_id } = payload;
let notifType = 'info';
let message = '';
let details = null;
switch (event) {
case 'started':
notifType = 'info';
message = `CLI ${tool} started`;
// Pass structured object for rich display
details = {
mode: mode,
prompt: prompt_preview
};
if (custom_id) {
details.id = custom_id;
}
break;
case 'completed':
if (success) {
notifType = 'success';
const turnStr = turn_count > 1 ? ` (turn ${turn_count})` : '';
message = `CLI ${tool} completed${turnStr}`;
// Pass structured object for rich display
details = {
duration: duration_ms ? `${(duration_ms / 1000).toFixed(1)}s` : '-',
execution_id: execution_id
};
if (turn_count > 1) {
details.turns = turn_count;
}
} else {
notifType = 'error';
message = `CLI ${tool} failed`;
details = {
status: status || 'Unknown error',
execution_id: execution_id
};
}
break;
case 'error':
notifType = 'error';
message = `CLI ${tool} error`;
details = error || 'Unknown error';
break;
default:
notifType = 'info';
message = `CLI ${tool}: ${event}`;
}
// Add to global notifications - pass objects for HTML formatting
if (typeof addGlobalNotification === 'function') {
addGlobalNotification(notifType, message, details, 'CLI');
}
// Refresh CLI history if on history view
if (event === 'completed' && typeof currentView !== 'undefined' &&
(currentView === 'history' || currentView === 'cli-history')) {
if (typeof loadCliHistory === 'function' && typeof renderCliHistoryView === 'function') {
loadCliHistory().then(() => renderCliHistoryView());
}
}
// Log to console
console.log(`[CLI Command] ${event}: ${tool}`, payload);
}
// ========== 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();
}
/**
* Handle REFRESH_REQUIRED events from CLI commands
* @param {Object} payload - Contains scope (memory|history|insights|all)
*/
function handleRefreshRequired(payload) {
const scope = payload?.scope || 'all';
console.log('[WS] Refresh required for scope:', scope);
switch (scope) {
case 'memory':
// Refresh memory stats and graph
if (typeof loadMemoryStats === 'function') {
loadMemoryStats().then(function() {
if (typeof renderHotspotsColumn === 'function') renderHotspotsColumn();
});
}
if (typeof loadMemoryGraph === 'function') {
loadMemoryGraph();
}
break;
case 'history':
// Refresh CLI history
if (typeof refreshCliHistory === 'function') {
refreshCliHistory();
}
break;
case 'insights':
// Refresh insights history
if (typeof loadInsightsHistory === 'function') {
loadInsightsHistory();
}
break;
case 'all':
default:
// Refresh everything
refreshIfNeeded();
if (typeof loadMemoryStats === 'function') {
loadMemoryStats().then(function() {
if (typeof renderHotspotsColumn === 'function') renderHotspotsColumn();
});
}
if (typeof refreshCliHistory === 'function') {
refreshCliHistory();
}
if (typeof loadInsightsHistory === 'function') {
loadInsightsHistory();
}
break;
}
}
// ========== 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);
}
}