mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
feat: Add comprehensive tests for CCW Loop System flow state
- Implemented loop control tasks in JSON format for testing. - Created comprehensive test scripts for loop flow and standalone tests. - Developed a shell script to automate the testing of the entire loop system flow, including mock endpoints and state transitions. - Added error handling and execution history tests to ensure robustness. - Established variable substitution and success condition evaluations in tests. - Set up cleanup and workspace management for test environments.
This commit is contained in:
@@ -2,6 +2,41 @@
|
||||
* Legacy Container Styles (kept for compatibility)
|
||||
* ======================================== */
|
||||
|
||||
/* CLI Stream Recovery Badge Styles */
|
||||
.cli-stream-recovery-badge {
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: hsl(38 92% 50% / 0.15);
|
||||
color: hsl(38 92% 50%);
|
||||
border-radius: 9999px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
margin-left: 0.375rem;
|
||||
}
|
||||
|
||||
.cli-status-recovery-badge {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: hsl(38 92% 50% / 0.15);
|
||||
color: hsl(38 92% 50%);
|
||||
border-radius: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Tab styling for recovered sessions */
|
||||
.cli-stream-tab.recovered {
|
||||
border-color: hsl(38 92% 50% / 0.3);
|
||||
}
|
||||
|
||||
.cli-stream-tab.recovered .cli-stream-recovery-badge {
|
||||
background: hsl(38 92% 50% / 0.2);
|
||||
color: hsl(38 92% 55%);
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.cli-manager-container {
|
||||
display: flex;
|
||||
|
||||
@@ -161,6 +161,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
/* Isolate from parent transform to fix native tooltip positioning */
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.cli-stream-action-btn {
|
||||
@@ -196,6 +198,10 @@
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
/* Fix native tooltip positioning under transformed parent */
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.cli-stream-close-btn:hover {
|
||||
@@ -203,6 +209,49 @@
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
/* Icon-only action buttons (cleaner style matching close button) */
|
||||
.cli-stream-icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
/* Fix native tooltip positioning under transformed parent */
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
/* Create new stacking context to isolate from parent transform */
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.cli-stream-icon-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.cli-stream-icon-btn:hover {
|
||||
background: hsl(var(--hover));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.cli-stream-icon-btn:first-child:hover {
|
||||
/* Clear completed - green/success tint */
|
||||
background: hsl(142 76% 36% / 0.1);
|
||||
color: hsl(142 76% 36%);
|
||||
}
|
||||
|
||||
.cli-stream-icon-btn:nth-child(2):hover {
|
||||
/* Clear all - orange/warning tint */
|
||||
background: hsl(38 92% 50% / 0.1);
|
||||
color: hsl(38 92% 50%);
|
||||
}
|
||||
|
||||
/* ===== Tab Bar ===== */
|
||||
.cli-stream-tabs {
|
||||
display: flex;
|
||||
@@ -787,6 +836,12 @@
|
||||
animation: streamBadgePulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.cli-stream-badge.has-completed {
|
||||
display: flex;
|
||||
background: hsl(var(--muted) / 0.8);
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
@keyframes streamBadgePulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.15); }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1877
ccw/src/templates/dashboard-css/36-loop-monitor.css.backup
Normal file
1877
ccw/src/templates/dashboard-css/36-loop-monitor.css.backup
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ let streamScrollHandler = null; // Track scroll listener
|
||||
let streamStatusTimers = []; // Track status update timers
|
||||
|
||||
// ===== State Management =====
|
||||
let cliStreamExecutions = {}; // { executionId: { tool, mode, output, status, startTime, endTime } }
|
||||
let cliStreamExecutions = {}; // { executionId: { tool, mode, output, status, startTime, endTime, recovered } }
|
||||
let activeStreamTab = null;
|
||||
let autoScrollEnabled = true;
|
||||
let isCliStreamViewerOpen = false;
|
||||
@@ -18,116 +18,212 @@ let searchFilter = ''; // Search filter for output content
|
||||
|
||||
const MAX_OUTPUT_LINES = 5000; // Prevent memory issues
|
||||
|
||||
// ===== Sync State Management =====
|
||||
let syncPromise = null; // Track ongoing sync to prevent duplicates
|
||||
let syncTimeoutId = null; // Debounce timeout ID
|
||||
let lastSyncTime = 0; // Track last successful sync time
|
||||
const SYNC_DEBOUNCE_MS = 300; // Debounce delay for sync calls
|
||||
const SYNC_TIMEOUT_MS = 10000; // 10 second timeout for sync requests
|
||||
|
||||
// ===== State Synchronization =====
|
||||
/**
|
||||
* Sync active executions from server
|
||||
* Called on initialization to recover state when view is opened mid-execution
|
||||
* Also called on WebSocket reconnection to restore CLI viewer state
|
||||
*
|
||||
* Features:
|
||||
* - Debouncing: Prevents rapid successive sync calls
|
||||
* - Deduplication: Only one sync at a time
|
||||
* - Timeout handling: 10 second timeout for sync requests
|
||||
* - Recovery flag: Marks recovered sessions for visual indicator
|
||||
*/
|
||||
async function syncActiveExecutions() {
|
||||
// Only sync in server mode
|
||||
if (!window.SERVER_MODE) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/cli/active');
|
||||
if (!response.ok) return;
|
||||
// Deduplication: if a sync is already in progress, return that promise
|
||||
if (syncPromise) {
|
||||
console.log('[CLI Stream] Sync already in progress, skipping');
|
||||
return syncPromise;
|
||||
}
|
||||
|
||||
const { executions } = await response.json();
|
||||
if (!executions || executions.length === 0) return;
|
||||
// Clear any pending debounced sync
|
||||
if (syncTimeoutId) {
|
||||
clearTimeout(syncTimeoutId);
|
||||
syncTimeoutId = null;
|
||||
}
|
||||
|
||||
let needsUiUpdate = false;
|
||||
syncPromise = (async function() {
|
||||
try {
|
||||
// Create timeout promise
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Sync timeout')), SYNC_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
executions.forEach(exec => {
|
||||
const existing = cliStreamExecutions[exec.id];
|
||||
// Race between fetch and timeout
|
||||
const response = await Promise.race([
|
||||
fetch('/api/cli/active'),
|
||||
timeoutPromise
|
||||
]);
|
||||
|
||||
// Parse historical output from server
|
||||
const historicalLines = [];
|
||||
if (exec.output) {
|
||||
const lines = exec.output.split('\n');
|
||||
const startIndex = Math.max(0, lines.length - MAX_OUTPUT_LINES + 1);
|
||||
lines.slice(startIndex).forEach(line => {
|
||||
if (line.trim()) {
|
||||
historicalLines.push({
|
||||
type: 'stdout',
|
||||
content: line,
|
||||
timestamp: exec.startTime || Date.now()
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
// Already tracked by WebSocket events - merge historical output
|
||||
// Only prepend historical lines that are not already in the output
|
||||
// (WebSocket events only add NEW output, so historical output should come before)
|
||||
const existingContentSet = new Set(existing.output.map(o => o.content));
|
||||
const missingLines = historicalLines.filter(h => !existingContentSet.has(h.content));
|
||||
|
||||
if (missingLines.length > 0) {
|
||||
// Find the system start message index (skip it when prepending)
|
||||
const systemMsgIndex = existing.output.findIndex(o => o.type === 'system');
|
||||
const insertIndex = systemMsgIndex >= 0 ? systemMsgIndex + 1 : 0;
|
||||
|
||||
// Prepend missing historical lines after system message
|
||||
existing.output.splice(insertIndex, 0, ...missingLines);
|
||||
|
||||
// Trim if too long
|
||||
if (existing.output.length > MAX_OUTPUT_LINES) {
|
||||
existing.output = existing.output.slice(-MAX_OUTPUT_LINES);
|
||||
}
|
||||
|
||||
needsUiUpdate = true;
|
||||
console.log(`[CLI Stream] Merged ${missingLines.length} historical lines for ${exec.id}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
console.warn('[CLI Stream] Sync response not OK:', response.status);
|
||||
return;
|
||||
}
|
||||
|
||||
needsUiUpdate = true;
|
||||
const { executions } = await response.json();
|
||||
|
||||
// New execution - rebuild full state
|
||||
cliStreamExecutions[exec.id] = {
|
||||
tool: exec.tool || 'cli',
|
||||
mode: exec.mode || 'analysis',
|
||||
output: [],
|
||||
status: exec.status || 'running',
|
||||
startTime: exec.startTime || Date.now(),
|
||||
endTime: null
|
||||
};
|
||||
// Handle empty response gracefully
|
||||
if (!executions || executions.length === 0) {
|
||||
console.log('[CLI Stream] No active executions to sync');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add system start message
|
||||
cliStreamExecutions[exec.id].output.push({
|
||||
type: 'system',
|
||||
content: `[${new Date(exec.startTime).toLocaleTimeString()}] CLI execution started: ${exec.tool} (${exec.mode} mode)`,
|
||||
timestamp: exec.startTime
|
||||
let needsUiUpdate = false;
|
||||
const now = Date.now();
|
||||
lastSyncTime = now;
|
||||
|
||||
executions.forEach(exec => {
|
||||
const existing = cliStreamExecutions[exec.id];
|
||||
|
||||
// Parse historical output from server with type detection
|
||||
const historicalLines = [];
|
||||
if (exec.output) {
|
||||
const lines = exec.output.split('\n');
|
||||
const startIndex = Math.max(0, lines.length - MAX_OUTPUT_LINES + 1);
|
||||
lines.slice(startIndex).forEach(line => {
|
||||
if (line.trim()) {
|
||||
// Detect type from content prefix for proper formatting
|
||||
const parsed = parseMessageType(line);
|
||||
// Map parsed type to chunkType for rendering
|
||||
const typeMap = {
|
||||
system: 'system',
|
||||
thinking: 'thought',
|
||||
response: 'stdout',
|
||||
result: 'metadata',
|
||||
error: 'stderr',
|
||||
warning: 'stderr',
|
||||
info: 'metadata'
|
||||
};
|
||||
historicalLines.push({
|
||||
type: parsed.hasPrefix ? (typeMap[parsed.type] || 'stdout') : 'stdout',
|
||||
content: line, // Keep original content with prefix
|
||||
timestamp: exec.startTime || Date.now()
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
// Already tracked by WebSocket events - merge historical output
|
||||
// Only prepend historical lines that are not already in the output
|
||||
// (WebSocket events only add NEW output, so historical output should come before)
|
||||
const existingContentSet = new Set(existing.output.map(o => o.content));
|
||||
const missingLines = historicalLines.filter(h => !existingContentSet.has(h.content));
|
||||
|
||||
if (missingLines.length > 0) {
|
||||
// Find the system start message index (skip it when prepending)
|
||||
const systemMsgIndex = existing.output.findIndex(o => o.type === 'system');
|
||||
const insertIndex = systemMsgIndex >= 0 ? systemMsgIndex + 1 : 0;
|
||||
|
||||
// Prepend missing historical lines after system message
|
||||
existing.output.splice(insertIndex, 0, ...missingLines);
|
||||
|
||||
// Trim if too long
|
||||
if (existing.output.length > MAX_OUTPUT_LINES) {
|
||||
existing.output = existing.output.slice(-MAX_OUTPUT_LINES);
|
||||
}
|
||||
|
||||
needsUiUpdate = true;
|
||||
console.log(`[CLI Stream] Merged ${missingLines.length} historical lines for ${exec.id}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
needsUiUpdate = true;
|
||||
|
||||
// New execution - rebuild full state with recovered flag
|
||||
cliStreamExecutions[exec.id] = {
|
||||
tool: exec.tool || 'cli',
|
||||
mode: exec.mode || 'analysis',
|
||||
output: [],
|
||||
status: exec.status || 'running',
|
||||
startTime: exec.startTime || Date.now(),
|
||||
endTime: exec.status !== 'running' ? Date.now() : null,
|
||||
recovered: true // Mark as recovered for visual indicator
|
||||
};
|
||||
|
||||
// Add system start message
|
||||
cliStreamExecutions[exec.id].output.push({
|
||||
type: 'system',
|
||||
content: `[${new Date(exec.startTime).toLocaleTimeString()}] CLI execution started: ${exec.tool} (${exec.mode} mode)`,
|
||||
timestamp: exec.startTime
|
||||
});
|
||||
|
||||
// Add historical output
|
||||
cliStreamExecutions[exec.id].output.push(...historicalLines);
|
||||
|
||||
// Add recovery notice for completed executions
|
||||
if (exec.isComplete) {
|
||||
cliStreamExecutions[exec.id].output.push({
|
||||
type: 'system',
|
||||
content: `[Session recovered from server - ${exec.status}]`,
|
||||
timestamp: now
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add historical output
|
||||
cliStreamExecutions[exec.id].output.push(...historicalLines);
|
||||
});
|
||||
// Update UI if we recovered or merged any executions
|
||||
if (needsUiUpdate) {
|
||||
// Set active tab to first running execution, or first recovered if none running
|
||||
const runningExec = executions.find(e => e.status === 'running');
|
||||
if (runningExec && !activeStreamTab) {
|
||||
activeStreamTab = runningExec.id;
|
||||
} else if (!runningExec && executions.length > 0 && !activeStreamTab) {
|
||||
// If no running executions, select the first recovered one
|
||||
activeStreamTab = executions[0].id;
|
||||
}
|
||||
|
||||
// Update UI if we recovered or merged any executions
|
||||
if (needsUiUpdate) {
|
||||
// Set active tab to first running execution
|
||||
const runningExec = executions.find(e => e.status === 'running');
|
||||
if (runningExec && !activeStreamTab) {
|
||||
activeStreamTab = runningExec.id;
|
||||
renderStreamTabs();
|
||||
updateStreamBadge();
|
||||
|
||||
// If viewer is open, render content. If not, open it if we have any recovered executions.
|
||||
if (isCliStreamViewerOpen) {
|
||||
renderStreamContent(activeStreamTab);
|
||||
} else if (executions.length > 0) {
|
||||
// Automatically open the viewer if it's closed and we just synced any executions
|
||||
// (running or completed - user might refresh after completion to see the output)
|
||||
toggleCliStreamViewer();
|
||||
}
|
||||
}
|
||||
|
||||
renderStreamTabs();
|
||||
updateStreamBadge();
|
||||
|
||||
// If viewer is open, render content. If not, and there's a running execution, open it.
|
||||
if (isCliStreamViewerOpen) {
|
||||
renderStreamContent(activeStreamTab);
|
||||
} else if (executions.some(e => e.status === 'running')) {
|
||||
// Automatically open the viewer if it's closed and we just synced a running task
|
||||
toggleCliStreamViewer();
|
||||
console.log(`[CLI Stream] Synced ${executions.length} active execution(s)`);
|
||||
} catch (e) {
|
||||
if (e.message === 'Sync timeout') {
|
||||
console.warn('[CLI Stream] Sync request timed out after', SYNC_TIMEOUT_MS, 'ms');
|
||||
} else {
|
||||
console.error('[CLI Stream] Sync failed:', e);
|
||||
}
|
||||
} finally {
|
||||
syncPromise = null; // Clear the promise to allow future syncs
|
||||
}
|
||||
})();
|
||||
|
||||
console.log(`[CLI Stream] Synced ${executions.length} active execution(s)`);
|
||||
} catch (e) {
|
||||
console.error('[CLI Stream] Sync failed:', e);
|
||||
return syncPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced sync function - prevents rapid successive sync calls
|
||||
* Use this when multiple sync triggers may happen in quick succession
|
||||
*/
|
||||
function syncActiveExecutionsDebounced() {
|
||||
if (syncTimeoutId) {
|
||||
clearTimeout(syncTimeoutId);
|
||||
}
|
||||
syncTimeoutId = setTimeout(function() {
|
||||
syncTimeoutId = null;
|
||||
syncActiveExecutions();
|
||||
}, SYNC_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
// ===== Initialization =====
|
||||
@@ -502,19 +598,24 @@ function renderStreamTabs() {
|
||||
tabsContainer.innerHTML = execIds.map(id => {
|
||||
const exec = cliStreamExecutions[id];
|
||||
const isActive = id === activeStreamTab;
|
||||
const canClose = exec.status !== 'running';
|
||||
|
||||
const isRecovered = exec.recovered === true;
|
||||
|
||||
// Recovery badge HTML
|
||||
const recoveryBadge = isRecovered
|
||||
? `<span class="cli-stream-recovery-badge" title="Session recovered after page refresh">Recovered</span>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="cli-stream-tab ${isActive ? 'active' : ''}"
|
||||
onclick="switchStreamTab('${id}')"
|
||||
<div class="cli-stream-tab ${isActive ? 'active' : ''} ${isRecovered ? 'recovered' : ''}"
|
||||
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'}"
|
||||
${recoveryBadge}
|
||||
<button class="cli-stream-tab-close"
|
||||
onclick="event.stopPropagation(); closeStream('${id}')"
|
||||
title="${canClose ? _streamT('cliStream.close') : _streamT('cliStream.cannotCloseRunning')}"
|
||||
${canClose ? '' : 'disabled'}>×</button>
|
||||
title="${_streamT('cliStream.close')}">×</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
@@ -589,29 +690,35 @@ function renderStreamContent(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
|
||||
|
||||
const duration = exec.endTime
|
||||
? formatDuration(exec.endTime - exec.startTime)
|
||||
: formatDuration(Date.now() - exec.startTime);
|
||||
|
||||
const statusLabel = exec.status === 'running'
|
||||
|
||||
const statusLabel = exec.status === 'running'
|
||||
? _streamT('cliStream.running')
|
||||
: exec.status === 'completed'
|
||||
? _streamT('cliStream.completed')
|
||||
: _streamT('cliStream.error');
|
||||
|
||||
|
||||
// Recovery badge for status bar
|
||||
const recoveryBadge = exec.recovered
|
||||
? `<span class="cli-status-recovery-badge">Recovered</span>`
|
||||
: '';
|
||||
|
||||
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>
|
||||
${recoveryBadge}
|
||||
</div>
|
||||
<div class="cli-stream-status-item">
|
||||
<i data-lucide="clock"></i>
|
||||
@@ -623,15 +730,15 @@ function renderStreamStatus(executionId) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="cli-stream-status-actions">
|
||||
<button class="cli-stream-toggle-btn ${autoScrollEnabled ? 'active' : ''}"
|
||||
onclick="toggleAutoScroll()"
|
||||
<button class="cli-stream-toggle-btn ${autoScrollEnabled ? 'active' : ''}"
|
||||
onclick="toggleAutoScroll()"
|
||||
title="${_streamT('cliStream.autoScroll')}">
|
||||
<i data-lucide="arrow-down-to-line"></i>
|
||||
<span data-i18n="cliStream.autoScroll">${_streamT('cliStream.autoScroll')}</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
|
||||
// Update duration periodically for running executions
|
||||
@@ -656,52 +763,85 @@ function switchStreamTab(executionId) {
|
||||
function updateStreamBadge() {
|
||||
const badge = document.getElementById('cliStreamBadge');
|
||||
if (!badge) return;
|
||||
|
||||
|
||||
const runningCount = Object.values(cliStreamExecutions).filter(e => e.status === 'running').length;
|
||||
|
||||
const totalCount = Object.keys(cliStreamExecutions).length;
|
||||
|
||||
if (runningCount > 0) {
|
||||
badge.textContent = runningCount;
|
||||
badge.classList.add('has-running');
|
||||
} else if (totalCount > 0) {
|
||||
// Show badge for completed executions too (with a different style)
|
||||
badge.textContent = totalCount;
|
||||
badge.classList.remove('has-running');
|
||||
badge.classList.add('has-completed');
|
||||
} else {
|
||||
badge.textContent = '';
|
||||
badge.classList.remove('has-running');
|
||||
badge.classList.remove('has-running', 'has-completed');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== User Actions =====
|
||||
function closeStream(executionId) {
|
||||
const exec = cliStreamExecutions[executionId];
|
||||
if (!exec || exec.status === 'running') return;
|
||||
|
||||
if (!exec) return;
|
||||
|
||||
// Note: We now allow closing running tasks - this just removes from view,
|
||||
// the actual CLI process continues on the server
|
||||
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();
|
||||
|
||||
// If no executions left, close the viewer
|
||||
if (Object.keys(cliStreamExecutions).length === 0) {
|
||||
toggleCliStreamViewer();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// If no executions left, close the viewer
|
||||
if (Object.keys(cliStreamExecutions).length === 0) {
|
||||
toggleCliStreamViewer();
|
||||
}
|
||||
}
|
||||
|
||||
function clearAllStreams() {
|
||||
// Clear all executions (both running and completed)
|
||||
const allIds = Object.keys(cliStreamExecutions);
|
||||
|
||||
allIds.forEach(id => delete cliStreamExecutions[id]);
|
||||
activeStreamTab = null;
|
||||
|
||||
renderStreamTabs();
|
||||
renderStreamContent(null);
|
||||
updateStreamBadge();
|
||||
|
||||
// Close the viewer since there's nothing to show
|
||||
toggleCliStreamViewer();
|
||||
}
|
||||
|
||||
function toggleAutoScroll() {
|
||||
@@ -839,6 +979,7 @@ window.handleCliStreamError = handleCliStreamError;
|
||||
window.switchStreamTab = switchStreamTab;
|
||||
window.closeStream = closeStream;
|
||||
window.clearCompletedStreams = clearCompletedStreams;
|
||||
window.clearAllStreams = clearAllStreams;
|
||||
window.toggleAutoScroll = toggleAutoScroll;
|
||||
window.handleSearchInput = handleSearchInput;
|
||||
window.clearSearch = clearSearch;
|
||||
|
||||
@@ -140,6 +140,22 @@ function initWebSocket() {
|
||||
|
||||
wsConnection.onopen = () => {
|
||||
console.log('[WS] Connected');
|
||||
|
||||
// Trigger CLI stream sync on WebSocket reconnection
|
||||
// This allows the viewer to recover after page refresh
|
||||
if (typeof syncActiveExecutions === 'function') {
|
||||
syncActiveExecutions().then(function() {
|
||||
console.log('[WS] CLI executions synced after connection');
|
||||
}).catch(function(err) {
|
||||
console.warn('[WS] Failed to sync CLI executions:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Emit custom event for other components to handle reconnection
|
||||
const reconnectEvent = new CustomEvent('websocket-reconnected', {
|
||||
detail: { timestamp: Date.now() }
|
||||
});
|
||||
window.dispatchEvent(reconnectEvent);
|
||||
};
|
||||
|
||||
wsConnection.onmessage = (event) => {
|
||||
|
||||
@@ -36,6 +36,7 @@ const i18n = {
|
||||
'common.disabled': 'Disabled',
|
||||
'common.yes': 'Yes',
|
||||
'common.no': 'No',
|
||||
'common.na': 'N/A',
|
||||
|
||||
// Header
|
||||
'header.project': 'Project:',
|
||||
@@ -2406,6 +2407,154 @@ const i18n = {
|
||||
'common.copyId': 'Copy ID',
|
||||
'common.copied': 'Copied!',
|
||||
'common.copyError': 'Failed to copy',
|
||||
|
||||
// Loop Monitor
|
||||
'loop.title': 'Loop Monitor',
|
||||
'loop.loops': 'Loops',
|
||||
'loop.all': 'All',
|
||||
'loop.running': 'Running',
|
||||
'loop.paused': 'Paused',
|
||||
'loop.completed': 'Completed',
|
||||
'loop.failed': 'Failed',
|
||||
'loop.tasks': 'Tasks',
|
||||
'loop.newLoop': 'New Loop',
|
||||
'loop.loading': 'Loading loops...',
|
||||
'loop.noLoops': 'No loops found',
|
||||
'loop.noLoopsHint': 'Create a loop task to get started',
|
||||
'loop.selectLoop': 'Select a loop to view details',
|
||||
'loop.selectLoopHint': 'Click on a loop from the list to see its details',
|
||||
'loop.loopNotFound': 'Loop not found',
|
||||
'loop.selectAnotherLoop': 'Select another loop from the list',
|
||||
'loop.task': 'Task',
|
||||
'loop.steps': 'steps',
|
||||
'loop.taskInfo': 'Task Info',
|
||||
'loop.edit': 'Edit',
|
||||
'loop.taskId': 'Task ID',
|
||||
'loop.step': 'Step',
|
||||
'loop.updated': 'Updated',
|
||||
'loop.created': 'Created',
|
||||
'loop.progress': 'Progress',
|
||||
'loop.iteration': 'Iteration',
|
||||
'loop.currentStep': 'Current Step',
|
||||
'loop.cliSequence': 'CLI Sequence',
|
||||
'loop.stateVariables': 'State Variables',
|
||||
'loop.executionHistory': 'Execution History',
|
||||
'loop.failureReason': 'Failure Reason',
|
||||
'loop.pause': 'Pause',
|
||||
'loop.resume': 'Resume',
|
||||
'loop.stop': 'Stop',
|
||||
'loop.confirmStop': 'Stop loop {loopId}?\n\nIteration: {currentIteration}/{maxIterations}\nThis action cannot be undone.',
|
||||
'loop.loopPaused': 'Loop paused',
|
||||
'loop.loopResumed': 'Loop resumed',
|
||||
'loop.loopStopped': 'Loop stopped',
|
||||
'loop.failedToPause': 'Failed to pause',
|
||||
'loop.failedToResume': 'Failed to resume',
|
||||
'loop.failedToStop': 'Failed to stop',
|
||||
'loop.failedToLoad': 'Failed to load loops',
|
||||
'loop.justNow': 'just now',
|
||||
'loop.minutesAgo': '{m}m ago',
|
||||
'loop.hoursAgo': '{h}h ago',
|
||||
'loop.daysAgo': '{d}d ago',
|
||||
'loop.tasksCount': '{count} task(s) with loop enabled',
|
||||
'loop.noLoopTasks': 'No loop-enabled tasks found',
|
||||
'loop.createLoopTask': 'Create Loop Task',
|
||||
'loop.backToLoops': 'Back to Loops',
|
||||
'loop.startLoop': 'Start Loop',
|
||||
'loop.loopStarted': 'Loop started',
|
||||
'loop.failedToStart': 'Failed to start loop',
|
||||
'loop.createTaskFailed': 'Failed to create task',
|
||||
'loop.createLoopModal': 'Create Loop Task',
|
||||
'loop.basicInfo': 'Basic Information',
|
||||
'loop.importFromIssue': 'Import from Issue',
|
||||
'loop.selectIssue': 'Select an Issue',
|
||||
'loop.noIssuesFound': 'No issues found',
|
||||
'loop.fetchIssuesFailed': 'Failed to fetch issues',
|
||||
'loop.fetchIssueFailed': 'Failed to fetch issue',
|
||||
'loop.issueImported': 'Issue imported',
|
||||
'loop.taskTitle': 'Task Title',
|
||||
'loop.taskTitlePlaceholder': 'e.g., Auto Test Fix Loop',
|
||||
'loop.description': 'Description',
|
||||
'loop.descriptionPlaceholder': 'Describe what this loop does...',
|
||||
'loop.loopConfig': 'Loop Configuration',
|
||||
'loop.maxIterations': 'Max Iterations',
|
||||
'loop.errorPolicy': 'Error Policy',
|
||||
'loop.pauseOnError': 'Pause on error',
|
||||
'loop.retryAutomatically': 'Retry automatically',
|
||||
'loop.failImmediately': 'Fail immediately',
|
||||
'loop.maxRetries': 'Max Retries (for retry policy)',
|
||||
'loop.successCondition': 'Success Condition (JavaScript expression)',
|
||||
'loop.successConditionPlaceholder': 'e.g., state_variables.test_stdout.includes(\'passed\')',
|
||||
'loop.availableVars': 'Available: state_variables, current_iteration',
|
||||
'loop.cliSequence': 'CLI Sequence',
|
||||
'loop.addStep': 'Add Step',
|
||||
'loop.stepNumber': 'Step {number}',
|
||||
'loop.stepLabel': 'Step',
|
||||
'loop.removeStep': 'Remove step',
|
||||
'loop.stepId': 'Step ID',
|
||||
'loop.stepIdPlaceholder': 'e.g., run_tests',
|
||||
'loop.tool': 'Tool',
|
||||
'loop.mode': 'Mode',
|
||||
'loop.command': 'Command',
|
||||
'loop.commandPlaceholder': 'e.g., npm test',
|
||||
'loop.promptTemplate': 'Prompt Template (supports [variable_name] substitution)',
|
||||
'loop.promptPlaceholder': 'Enter prompt template...',
|
||||
'loop.onError': 'On Error',
|
||||
'loop.continue': 'Continue',
|
||||
'loop.pause': 'Pause',
|
||||
'loop.failFast': 'Fail Fast',
|
||||
'loop.cancel': 'Cancel',
|
||||
'loop.createAndStart': 'Create Loop',
|
||||
'loop.created': 'Created',
|
||||
'loop.createFailed': 'Create Loop Failed',
|
||||
'loop.taskCreated': 'Task created',
|
||||
'loop.taskCreatedFailedStart': 'Task created but failed to start loop',
|
||||
// V2 Simplified Loop
|
||||
'loop.create': 'Create',
|
||||
'loop.loopCreated': 'Loop created successfully',
|
||||
'loop.titleRequired': 'Title is required',
|
||||
'loop.invalidMaxIterations': 'Max iterations must be between 1 and 100',
|
||||
'loop.loopInfo': 'Loop Info',
|
||||
'loop.v2LoopInfo': 'This is a simplified loop. Tasks are managed independently in the detail view.',
|
||||
'loop.manageTasks': 'Manage Tasks',
|
||||
'loop.taskManagement': 'Task Management',
|
||||
'loop.taskManagementPlaceholder': 'Task management will be available in the next update. Use the v1 loops for full task configuration.',
|
||||
'loop.noTasksYet': 'No tasks configured yet',
|
||||
'loop.back': 'Back',
|
||||
'loop.loopNotFound': 'Loop not found',
|
||||
'loop.selectAnotherLoop': 'Please select another loop from the list',
|
||||
'loop.start': 'Start',
|
||||
'loop.loopStarted': 'Loop started',
|
||||
'loop.failedToStart': 'Failed to start loop',
|
||||
// Task List Management
|
||||
'loop.taskList': 'Task List',
|
||||
'loop.addTask': 'Add Task',
|
||||
'loop.taskDescription': 'Task Description',
|
||||
'loop.taskDescriptionPlaceholder': 'Describe what this task should do...',
|
||||
'loop.modeAnalysis': 'Analysis',
|
||||
'loop.modeWrite': 'Write',
|
||||
'loop.modeReview': 'Review',
|
||||
'loop.save': 'Save',
|
||||
'loop.taskAdded': 'Task added successfully',
|
||||
'loop.addTaskFailed': 'Failed to add task',
|
||||
'loop.editTask': 'Edit Task',
|
||||
'loop.taskUpdated': 'Task updated successfully',
|
||||
'loop.updateTaskFailed': 'Failed to update task',
|
||||
'loop.confirmDeleteTask': 'Are you sure you want to delete this task? This action cannot be undone.',
|
||||
'loop.taskDeleted': 'Task deleted successfully',
|
||||
'loop.deleteTaskFailed': 'Failed to delete task',
|
||||
'loop.deleteTaskError': 'Error deleting task',
|
||||
'loop.loadTasksFailed': 'Failed to load tasks',
|
||||
'loop.loadTasksError': 'Error loading tasks',
|
||||
'loop.tasksReordered': 'Tasks reordered',
|
||||
'loop.saveOrderFailed': 'Failed to save order',
|
||||
'loop.noTasksHint': 'Add your first task to get started',
|
||||
'loop.noDescription': 'No description',
|
||||
'loop.descriptionRequired': 'Description is required',
|
||||
'loop.loadTaskFailed': 'Failed to load task',
|
||||
'loop.loadTaskError': 'Error loading task',
|
||||
'loop.taskTitleHint': 'Enter a descriptive title for your loop',
|
||||
'loop.descriptionHint': 'Optional context about what this loop does',
|
||||
'loop.maxIterationsHint': 'Maximum number of iterations to run (1-100)',
|
||||
},
|
||||
|
||||
zh: {
|
||||
@@ -2436,6 +2585,7 @@ const i18n = {
|
||||
'common.disabled': '已禁用',
|
||||
'common.yes': '是',
|
||||
'common.no': '否',
|
||||
'common.na': '无',
|
||||
|
||||
// Header
|
||||
'header.project': '项目:',
|
||||
@@ -4818,6 +4968,153 @@ const i18n = {
|
||||
'common.copyId': '复制 ID',
|
||||
'common.copied': '已复制!',
|
||||
'common.copyError': '复制失败',
|
||||
|
||||
// Loop Monitor - 循环监控
|
||||
'loop.title': '循环监控',
|
||||
'loop.loops': '循环',
|
||||
'loop.all': '全部',
|
||||
'loop.running': '运行中',
|
||||
'loop.paused': '已暂停',
|
||||
'loop.completed': '已完成',
|
||||
'loop.failed': '失败',
|
||||
'loop.tasks': '任务',
|
||||
'loop.newLoop': '新建循环',
|
||||
'loop.loading': '加载循环中...',
|
||||
'loop.noLoops': '未找到循环',
|
||||
'loop.noLoopsHint': '创建一个循环任务开始使用',
|
||||
'loop.selectLoop': '选择一个循环查看详情',
|
||||
'loop.selectLoopHint': '点击列表中的循环查看其详细信息',
|
||||
'loop.loopNotFound': '循环未找到',
|
||||
'loop.selectAnotherLoop': '从列表中选择另一个循环',
|
||||
'loop.task': '任务',
|
||||
'loop.steps': '个步骤',
|
||||
'loop.taskInfo': '任务信息',
|
||||
'loop.edit': '编辑',
|
||||
'loop.taskId': '任务 ID',
|
||||
'loop.step': '步骤',
|
||||
'loop.updated': '更新时间',
|
||||
'loop.created': '创建时间',
|
||||
'loop.progress': '进度',
|
||||
'loop.iteration': '迭代',
|
||||
'loop.currentStep': '当前步骤',
|
||||
'loop.cliSequence': 'CLI 序列',
|
||||
'loop.stateVariables': '状态变量',
|
||||
'loop.executionHistory': '执行历史',
|
||||
'loop.failureReason': '失败原因',
|
||||
'loop.pause': '暂停',
|
||||
'loop.resume': '恢复',
|
||||
'loop.stop': '停止',
|
||||
'loop.confirmStop': '确定停止循环 {loopId}?\n\n迭代:{currentIteration}/{maxIterations}\n此操作无法撤销。',
|
||||
'loop.loopPaused': '循环已暂停',
|
||||
'loop.loopResumed': '循环已恢复',
|
||||
'loop.loopStopped': '循环已停止',
|
||||
'loop.failedToPause': '暂停失败',
|
||||
'loop.failedToResume': '恢复失败',
|
||||
'loop.failedToStop': '停止失败',
|
||||
'loop.failedToLoad': '加载循环失败',
|
||||
'loop.justNow': '刚刚',
|
||||
'loop.minutesAgo': '{m} 分钟前',
|
||||
'loop.hoursAgo': '{h} 小时前',
|
||||
'loop.daysAgo': '{d} 天前',
|
||||
'loop.tasksCount': '{count} 个启用循环的任务',
|
||||
'loop.noLoopTasks': '未找到启用循环的任务',
|
||||
'loop.createLoopTask': '创建循环任务',
|
||||
'loop.backToLoops': '返回循环列表',
|
||||
'loop.startLoop': '启动循环',
|
||||
'loop.loopStarted': '循环已启动',
|
||||
'loop.failedToStart': '启动循环失败',
|
||||
'loop.createTaskFailed': '创建任务失败',
|
||||
'loop.createLoopModal': '创建循环任务',
|
||||
'loop.basicInfo': '基本信息',
|
||||
'loop.importFromIssue': '从问题导入',
|
||||
'loop.selectIssue': '选择问题',
|
||||
'loop.noIssuesFound': '未找到问题',
|
||||
'loop.fetchIssuesFailed': '获取问题列表失败',
|
||||
'loop.fetchIssueFailed': '获取问题详情失败',
|
||||
'loop.issueImported': '已导入问题',
|
||||
'loop.taskTitle': '任务标题',
|
||||
'loop.taskTitlePlaceholder': '例如:自动测试修复循环',
|
||||
'loop.description': '描述',
|
||||
'loop.descriptionPlaceholder': '描述此循环的功能...',
|
||||
'loop.loopConfig': '循环配置',
|
||||
'loop.maxIterations': '最大迭代次数',
|
||||
'loop.errorPolicy': '错误策略',
|
||||
'loop.pauseOnError': '暂停',
|
||||
'loop.retryAutomatically': '自动重试',
|
||||
'loop.failImmediately': '立即失败',
|
||||
'loop.maxRetries': '最大重试次数(重试策略)',
|
||||
'loop.successCondition': '成功条件(JavaScript 表达式)',
|
||||
'loop.successConditionPlaceholder': '例如:state_variables.test_stdout.includes(\'passed\')',
|
||||
'loop.availableVars': '可用变量:state_variables、current_iteration',
|
||||
'loop.cliSequence': 'CLI 序列',
|
||||
'loop.addStep': '添加步骤',
|
||||
'loop.stepNumber': '步骤 {number}',
|
||||
'loop.stepLabel': '步骤',
|
||||
'loop.removeStep': '移除步骤',
|
||||
'loop.stepId': '步骤 ID',
|
||||
'loop.stepIdPlaceholder': '例如:run_tests',
|
||||
'loop.tool': '工具',
|
||||
'loop.mode': '模式',
|
||||
'loop.command': '命令',
|
||||
'loop.commandPlaceholder': '例如:npm test',
|
||||
'loop.promptTemplate': '提示模板(支持 [variable_name] 变量替换)',
|
||||
'loop.promptPlaceholder': '输入提示模板...',
|
||||
'loop.onError': '错误处理',
|
||||
'loop.continue': '继续',
|
||||
'loop.pause': '暂停',
|
||||
'loop.failFast': '立即失败',
|
||||
'loop.cancel': '取消',
|
||||
'loop.createAndStart': '创建循环',
|
||||
'loop.created': '已创建',
|
||||
'loop.createFailed': '创建循环失败',
|
||||
'loop.taskCreatedFailedStart': '任务已创建,但启动循环失败',
|
||||
// V2 Simplified Loop
|
||||
'loop.create': '创建',
|
||||
'loop.loopCreated': '循环创建成功',
|
||||
'loop.titleRequired': '标题不能为空',
|
||||
'loop.invalidMaxIterations': '最大迭代次数必须在 1 到 100 之间',
|
||||
'loop.loopInfo': '循环信息',
|
||||
'loop.v2LoopInfo': '这是一个简化版循环。任务在详情视图中独立管理。',
|
||||
'loop.manageTasks': '管理任务',
|
||||
'loop.taskManagement': '任务管理',
|
||||
'loop.taskManagementPlaceholder': '任务管理将在后续更新中提供。请使用 v1 循环进行完整任务配置。',
|
||||
'loop.noTasksYet': '尚未配置任务',
|
||||
'loop.back': '返回',
|
||||
'loop.loopNotFound': '循环未找到',
|
||||
'loop.selectAnotherLoop': '请从列表中选择其他循环',
|
||||
'loop.start': '启动',
|
||||
'loop.loopStarted': '循环已启动',
|
||||
'loop.failedToStart': '启动循环失败',
|
||||
// Task List Management
|
||||
'loop.taskList': '任务列表',
|
||||
'loop.addTask': '添加任务',
|
||||
'loop.taskDescription': '任务描述',
|
||||
'loop.taskDescriptionPlaceholder': '描述此任务应该做什么...',
|
||||
'loop.modeAnalysis': '分析',
|
||||
'loop.modeWrite': '编写',
|
||||
'loop.modeReview': '审查',
|
||||
'loop.save': '保存',
|
||||
'loop.taskAdded': '任务添加成功',
|
||||
'loop.addTaskFailed': '添加任务失败',
|
||||
'loop.editTask': '编辑任务',
|
||||
'loop.taskUpdated': '任务更新成功',
|
||||
'loop.updateTaskFailed': '更新任务失败',
|
||||
'loop.confirmDeleteTask': '确定要删除此任务吗?此操作无法撤销。',
|
||||
'loop.taskDeleted': '任务删除成功',
|
||||
'loop.deleteTaskFailed': '删除任务失败',
|
||||
'loop.deleteTaskError': '删除任务时出错',
|
||||
'loop.loadTasksFailed': '加载任务失败',
|
||||
'loop.loadTasksError': '加载任务时出错',
|
||||
'loop.tasksReordered': '任务已重新排序',
|
||||
'loop.saveOrderFailed': '保存排序失败',
|
||||
'loop.noTasksHint': '添加您的第一个任务开始使用',
|
||||
'loop.noDescription': '无描述',
|
||||
'loop.descriptionRequired': '描述不能为空',
|
||||
'loop.loadTaskFailed': '加载任务失败',
|
||||
'loop.loadTaskError': '加载任务时出错',
|
||||
'loop.taskTitleHint': '为循环输入描述性标题',
|
||||
'loop.descriptionHint': '关于循环功能的可选上下文',
|
||||
'loop.maxIterationsHint': '最大迭代次数 (1-100)',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4872,11 +5169,24 @@ function switchLang(lang) {
|
||||
localStorage.setItem('ccw-lang', lang);
|
||||
applyTranslations();
|
||||
updateLangToggle();
|
||||
|
||||
|
||||
// Re-render current view to update dynamic content
|
||||
if (typeof updateContentTitle === 'function') {
|
||||
updateContentTitle();
|
||||
}
|
||||
|
||||
// Re-render loop monitor if visible
|
||||
if (typeof window.selectedLoopId !== 'undefined' && document.getElementById('loopList')) {
|
||||
if (typeof updateLoopStatusLabels === 'function') {
|
||||
updateLoopStatusLabels();
|
||||
}
|
||||
if (typeof renderLoopList === 'function') {
|
||||
renderLoopList();
|
||||
}
|
||||
if (window.selectedLoopId && typeof renderLoopDetail === 'function') {
|
||||
renderLoopDetail(window.selectedLoopId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -130,8 +130,9 @@ async function initCsrfToken() {
|
||||
/**
|
||||
* Sync active CLI executions from server
|
||||
* Called when view is opened to restore running execution state
|
||||
* Note: Renamed from syncActiveExecutions to avoid conflict with cli-stream-viewer.js
|
||||
*/
|
||||
async function syncActiveExecutions() {
|
||||
async function syncActiveExecutionsForManager() {
|
||||
try {
|
||||
var response = await fetch('/api/cli/active');
|
||||
if (!response.ok) return;
|
||||
@@ -1202,7 +1203,7 @@ async function renderCliManager() {
|
||||
}
|
||||
|
||||
// 同步活动执行
|
||||
syncActiveExecutions();
|
||||
syncActiveExecutionsForManager();
|
||||
}
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -767,9 +767,11 @@
|
||||
<button class="cli-stream-search-clear" onclick="clearSearch()" title="Clear search">×</button>
|
||||
</div>
|
||||
<div class="cli-stream-actions">
|
||||
<button class="cli-stream-action-btn" onclick="clearCompletedStreams()" data-i18n="cliStream.clearCompleted">
|
||||
<button class="cli-stream-icon-btn" onclick="clearCompletedStreams()" title="Clear completed">
|
||||
<i data-lucide="check-circle"></i>
|
||||
</button>
|
||||
<button class="cli-stream-icon-btn" onclick="clearAllStreams()" title="Clear all">
|
||||
<i data-lucide="trash-2"></i>
|
||||
<span>Clear</span>
|
||||
</button>
|
||||
<button class="cli-stream-close-btn" onclick="toggleCliStreamViewer()" title="Close">×</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user