mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-12 02:37:45 +08:00
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.
This commit is contained in:
456
ccw/src/templates/dashboard-js/components/cli-stream-viewer.js
Normal file
456
ccw/src/templates/dashboard-js/components/cli-stream-viewer.js
Normal file
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* CLI Stream Viewer Component
|
||||
* Real-time streaming output viewer for CLI executions
|
||||
*/
|
||||
|
||||
// ===== State Management =====
|
||||
let cliStreamExecutions = {}; // { executionId: { tool, mode, output, status, startTime, endTime } }
|
||||
let activeStreamTab = null;
|
||||
let autoScrollEnabled = true;
|
||||
let isCliStreamViewerOpen = false;
|
||||
|
||||
const MAX_OUTPUT_LINES = 5000; // Prevent memory issues
|
||||
|
||||
// ===== Initialization =====
|
||||
function initCliStreamViewer() {
|
||||
// Initialize keyboard shortcuts
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && isCliStreamViewerOpen) {
|
||||
toggleCliStreamViewer();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize scroll detection for auto-scroll
|
||||
const content = document.getElementById('cliStreamContent');
|
||||
if (content) {
|
||||
content.addEventListener('scroll', handleStreamContentScroll);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Panel Control =====
|
||||
function toggleCliStreamViewer() {
|
||||
const viewer = document.getElementById('cliStreamViewer');
|
||||
const overlay = document.getElementById('cliStreamOverlay');
|
||||
|
||||
if (!viewer || !overlay) return;
|
||||
|
||||
isCliStreamViewerOpen = !isCliStreamViewerOpen;
|
||||
|
||||
if (isCliStreamViewerOpen) {
|
||||
viewer.classList.add('open');
|
||||
overlay.classList.add('open');
|
||||
|
||||
// If no active tab but have executions, select the first one
|
||||
if (!activeStreamTab && Object.keys(cliStreamExecutions).length > 0) {
|
||||
const firstId = Object.keys(cliStreamExecutions)[0];
|
||||
switchStreamTab(firstId);
|
||||
} else {
|
||||
renderStreamContent(activeStreamTab);
|
||||
}
|
||||
|
||||
// Re-init lucide icons
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
} else {
|
||||
viewer.classList.remove('open');
|
||||
overlay.classList.remove('open');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== WebSocket Event Handlers =====
|
||||
function handleCliStreamStarted(payload) {
|
||||
const { executionId, tool, mode, timestamp } = payload;
|
||||
|
||||
// Create new execution record
|
||||
cliStreamExecutions[executionId] = {
|
||||
tool: tool || 'cli',
|
||||
mode: mode || 'analysis',
|
||||
output: [],
|
||||
status: 'running',
|
||||
startTime: timestamp ? new Date(timestamp).getTime() : Date.now(),
|
||||
endTime: null
|
||||
};
|
||||
|
||||
// Add system message
|
||||
cliStreamExecutions[executionId].output.push({
|
||||
type: 'system',
|
||||
content: `[${new Date().toLocaleTimeString()}] CLI execution started: ${tool} (${mode} mode)`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// If this is the first execution or panel is open, select it
|
||||
if (!activeStreamTab || isCliStreamViewerOpen) {
|
||||
activeStreamTab = executionId;
|
||||
}
|
||||
|
||||
renderStreamTabs();
|
||||
renderStreamContent(activeStreamTab);
|
||||
updateStreamBadge();
|
||||
|
||||
// Auto-open panel if configured (optional)
|
||||
// if (!isCliStreamViewerOpen) toggleCliStreamViewer();
|
||||
}
|
||||
|
||||
function handleCliStreamOutput(payload) {
|
||||
const { executionId, chunkType, data } = payload;
|
||||
|
||||
const exec = cliStreamExecutions[executionId];
|
||||
if (!exec) return;
|
||||
|
||||
// Parse and add output lines
|
||||
const content = typeof data === 'string' ? data : JSON.stringify(data);
|
||||
const lines = content.split('\n');
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.trim() || lines.length === 1) { // Keep empty lines if it's the only content
|
||||
exec.output.push({
|
||||
type: chunkType || 'stdout',
|
||||
content: line,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Trim if too long
|
||||
if (exec.output.length > MAX_OUTPUT_LINES) {
|
||||
exec.output = exec.output.slice(-MAX_OUTPUT_LINES);
|
||||
}
|
||||
|
||||
// Update UI if this is the active tab
|
||||
if (activeStreamTab === executionId && isCliStreamViewerOpen) {
|
||||
requestAnimationFrame(() => {
|
||||
renderStreamContent(executionId);
|
||||
});
|
||||
}
|
||||
|
||||
// Update badge to show activity
|
||||
updateStreamBadge();
|
||||
}
|
||||
|
||||
function handleCliStreamCompleted(payload) {
|
||||
const { executionId, success, duration, timestamp } = payload;
|
||||
|
||||
const exec = cliStreamExecutions[executionId];
|
||||
if (!exec) return;
|
||||
|
||||
exec.status = success ? 'completed' : 'error';
|
||||
exec.endTime = timestamp ? new Date(timestamp).getTime() : Date.now();
|
||||
|
||||
// Add completion message
|
||||
const durationText = duration ? ` (${formatDuration(duration)})` : '';
|
||||
const statusText = success ? 'completed successfully' : 'failed';
|
||||
exec.output.push({
|
||||
type: 'system',
|
||||
content: `[${new Date().toLocaleTimeString()}] CLI execution ${statusText}${durationText}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
renderStreamTabs();
|
||||
if (activeStreamTab === executionId) {
|
||||
renderStreamContent(executionId);
|
||||
}
|
||||
updateStreamBadge();
|
||||
}
|
||||
|
||||
function handleCliStreamError(payload) {
|
||||
const { executionId, error, timestamp } = payload;
|
||||
|
||||
const exec = cliStreamExecutions[executionId];
|
||||
if (!exec) return;
|
||||
|
||||
exec.status = 'error';
|
||||
exec.endTime = timestamp ? new Date(timestamp).getTime() : Date.now();
|
||||
|
||||
// Add error message
|
||||
exec.output.push({
|
||||
type: 'stderr',
|
||||
content: `[ERROR] ${error || 'Unknown error occurred'}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
renderStreamTabs();
|
||||
if (activeStreamTab === executionId) {
|
||||
renderStreamContent(executionId);
|
||||
}
|
||||
updateStreamBadge();
|
||||
}
|
||||
|
||||
// ===== UI Rendering =====
|
||||
function renderStreamTabs() {
|
||||
const tabsContainer = document.getElementById('cliStreamTabs');
|
||||
if (!tabsContainer) return;
|
||||
|
||||
const execIds = Object.keys(cliStreamExecutions);
|
||||
|
||||
if (execIds.length === 0) {
|
||||
tabsContainer.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort: running first, then by start time (newest first)
|
||||
execIds.sort((a, b) => {
|
||||
const execA = cliStreamExecutions[a];
|
||||
const execB = cliStreamExecutions[b];
|
||||
|
||||
if (execA.status === 'running' && execB.status !== 'running') return -1;
|
||||
if (execA.status !== 'running' && execB.status === 'running') return 1;
|
||||
return execB.startTime - execA.startTime;
|
||||
});
|
||||
|
||||
tabsContainer.innerHTML = execIds.map(id => {
|
||||
const exec = cliStreamExecutions[id];
|
||||
const isActive = id === activeStreamTab;
|
||||
const canClose = exec.status !== 'running';
|
||||
|
||||
return `
|
||||
<div class="cli-stream-tab ${isActive ? 'active' : ''}"
|
||||
onclick="switchStreamTab('${id}')"
|
||||
data-execution-id="${id}">
|
||||
<span class="cli-stream-tab-status ${exec.status}"></span>
|
||||
<span class="cli-stream-tab-tool">${escapeHtml(exec.tool)}</span>
|
||||
<span class="cli-stream-tab-mode">${exec.mode}</span>
|
||||
<button class="cli-stream-tab-close ${canClose ? '' : 'disabled'}"
|
||||
onclick="event.stopPropagation(); closeStream('${id}')"
|
||||
title="${canClose ? t('cliStream.close') : t('cliStream.cannotCloseRunning')}"
|
||||
${canClose ? '' : 'disabled'}>×</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Update count badge
|
||||
const countBadge = document.getElementById('cliStreamCountBadge');
|
||||
if (countBadge) {
|
||||
const runningCount = execIds.filter(id => cliStreamExecutions[id].status === 'running').length;
|
||||
countBadge.textContent = execIds.length;
|
||||
countBadge.classList.toggle('has-running', runningCount > 0);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStreamContent(executionId) {
|
||||
const contentContainer = document.getElementById('cliStreamContent');
|
||||
if (!contentContainer) return;
|
||||
|
||||
const exec = executionId ? cliStreamExecutions[executionId] : null;
|
||||
|
||||
if (!exec) {
|
||||
// Show empty state
|
||||
contentContainer.innerHTML = `
|
||||
<div class="cli-stream-empty">
|
||||
<i data-lucide="terminal"></i>
|
||||
<div class="cli-stream-empty-title" data-i18n="cliStream.noStreams">${t('cliStream.noStreams')}</div>
|
||||
<div class="cli-stream-empty-hint" data-i18n="cliStream.noStreamsHint">${t('cliStream.noStreamsHint')}</div>
|
||||
</div>
|
||||
`;
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if should auto-scroll
|
||||
const wasAtBottom = contentContainer.scrollHeight - contentContainer.scrollTop <= contentContainer.clientHeight + 50;
|
||||
|
||||
// Render output lines
|
||||
contentContainer.innerHTML = exec.output.map(line =>
|
||||
`<div class="cli-stream-line ${line.type}">${escapeHtml(line.content)}</div>`
|
||||
).join('');
|
||||
|
||||
// Auto-scroll if enabled and was at bottom
|
||||
if (autoScrollEnabled && wasAtBottom) {
|
||||
contentContainer.scrollTop = contentContainer.scrollHeight;
|
||||
}
|
||||
|
||||
// Update status bar
|
||||
renderStreamStatus(executionId);
|
||||
}
|
||||
|
||||
function renderStreamStatus(executionId) {
|
||||
const statusContainer = document.getElementById('cliStreamStatus');
|
||||
if (!statusContainer) return;
|
||||
|
||||
const exec = executionId ? cliStreamExecutions[executionId] : null;
|
||||
|
||||
if (!exec) {
|
||||
statusContainer.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = exec.endTime
|
||||
? formatDuration(exec.endTime - exec.startTime)
|
||||
: formatDuration(Date.now() - exec.startTime);
|
||||
|
||||
const statusLabel = exec.status === 'running'
|
||||
? t('cliStream.running')
|
||||
: exec.status === 'completed'
|
||||
? t('cliStream.completed')
|
||||
: t('cliStream.error');
|
||||
|
||||
statusContainer.innerHTML = `
|
||||
<div class="cli-stream-status-info">
|
||||
<div class="cli-stream-status-item">
|
||||
<span class="cli-stream-tab-status ${exec.status}"></span>
|
||||
<span>${statusLabel}</span>
|
||||
</div>
|
||||
<div class="cli-stream-status-item">
|
||||
<i data-lucide="clock"></i>
|
||||
<span>${duration}</span>
|
||||
</div>
|
||||
<div class="cli-stream-status-item">
|
||||
<i data-lucide="file-text"></i>
|
||||
<span>${exec.output.length} ${t('cliStream.lines') || 'lines'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cli-stream-status-actions">
|
||||
<button class="cli-stream-toggle-btn ${autoScrollEnabled ? 'active' : ''}"
|
||||
onclick="toggleAutoScroll()"
|
||||
title="${t('cliStream.autoScroll')}">
|
||||
<i data-lucide="arrow-down-to-line"></i>
|
||||
<span data-i18n="cliStream.autoScroll">${t('cliStream.autoScroll')}</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
|
||||
// Update duration periodically for running executions
|
||||
if (exec.status === 'running') {
|
||||
setTimeout(() => {
|
||||
if (activeStreamTab === executionId && cliStreamExecutions[executionId]?.status === 'running') {
|
||||
renderStreamStatus(executionId);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function switchStreamTab(executionId) {
|
||||
if (!cliStreamExecutions[executionId]) return;
|
||||
|
||||
activeStreamTab = executionId;
|
||||
renderStreamTabs();
|
||||
renderStreamContent(executionId);
|
||||
}
|
||||
|
||||
function updateStreamBadge() {
|
||||
const badge = document.getElementById('cliStreamBadge');
|
||||
if (!badge) return;
|
||||
|
||||
const runningCount = Object.values(cliStreamExecutions).filter(e => e.status === 'running').length;
|
||||
|
||||
if (runningCount > 0) {
|
||||
badge.textContent = runningCount;
|
||||
badge.classList.add('has-running');
|
||||
} else {
|
||||
badge.textContent = '';
|
||||
badge.classList.remove('has-running');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== User Actions =====
|
||||
function closeStream(executionId) {
|
||||
const exec = cliStreamExecutions[executionId];
|
||||
if (!exec || exec.status === 'running') return;
|
||||
|
||||
delete cliStreamExecutions[executionId];
|
||||
|
||||
// Switch to another tab if this was active
|
||||
if (activeStreamTab === executionId) {
|
||||
const remaining = Object.keys(cliStreamExecutions);
|
||||
activeStreamTab = remaining.length > 0 ? remaining[0] : null;
|
||||
}
|
||||
|
||||
renderStreamTabs();
|
||||
renderStreamContent(activeStreamTab);
|
||||
updateStreamBadge();
|
||||
}
|
||||
|
||||
function clearCompletedStreams() {
|
||||
const toRemove = Object.keys(cliStreamExecutions).filter(
|
||||
id => cliStreamExecutions[id].status !== 'running'
|
||||
);
|
||||
|
||||
toRemove.forEach(id => delete cliStreamExecutions[id]);
|
||||
|
||||
// Update active tab if needed
|
||||
if (activeStreamTab && !cliStreamExecutions[activeStreamTab]) {
|
||||
const remaining = Object.keys(cliStreamExecutions);
|
||||
activeStreamTab = remaining.length > 0 ? remaining[0] : null;
|
||||
}
|
||||
|
||||
renderStreamTabs();
|
||||
renderStreamContent(activeStreamTab);
|
||||
updateStreamBadge();
|
||||
}
|
||||
|
||||
function toggleAutoScroll() {
|
||||
autoScrollEnabled = !autoScrollEnabled;
|
||||
|
||||
if (autoScrollEnabled && activeStreamTab) {
|
||||
const content = document.getElementById('cliStreamContent');
|
||||
if (content) {
|
||||
content.scrollTop = content.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
renderStreamStatus(activeStreamTab);
|
||||
}
|
||||
|
||||
function handleStreamContentScroll() {
|
||||
const content = document.getElementById('cliStreamContent');
|
||||
if (!content) return;
|
||||
|
||||
// If user scrolls up, disable auto-scroll
|
||||
const isAtBottom = content.scrollHeight - content.scrollTop <= content.clientHeight + 50;
|
||||
if (!isAtBottom && autoScrollEnabled) {
|
||||
autoScrollEnabled = false;
|
||||
renderStreamStatus(activeStreamTab);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Helper Functions =====
|
||||
function formatDuration(ms) {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Translation helper with fallback
|
||||
function t(key) {
|
||||
if (typeof window.t === 'function') {
|
||||
return window.t(key);
|
||||
}
|
||||
// Fallback values
|
||||
const fallbacks = {
|
||||
'cliStream.noStreams': 'No active CLI executions',
|
||||
'cliStream.noStreamsHint': 'Start a CLI command to see streaming output',
|
||||
'cliStream.running': 'Running',
|
||||
'cliStream.completed': 'Completed',
|
||||
'cliStream.error': 'Error',
|
||||
'cliStream.autoScroll': 'Auto-scroll',
|
||||
'cliStream.close': 'Close',
|
||||
'cliStream.cannotCloseRunning': 'Cannot close running execution',
|
||||
'cliStream.lines': 'lines'
|
||||
};
|
||||
return fallbacks[key] || key;
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initCliStreamViewer);
|
||||
} else {
|
||||
initCliStreamViewer();
|
||||
}
|
||||
Reference in New Issue
Block a user