Files
Claude-Code-Workflow/ccw/src/templates/dashboard-js/components/cli-stream-viewer.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

457 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
}