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:
catlog22
2026-01-22 10:13:00 +08:00
parent d9f1d14d5e
commit 60eab98782
37 changed files with 12347 additions and 917 deletions

View File

@@ -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;

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View File

@@ -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) => {

View File

@@ -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);
}
}
}
}

View File

@@ -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

View File

@@ -767,9 +767,11 @@
<button class="cli-stream-search-clear" onclick="clearSearch()" title="Clear search">&times;</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">&times;</button>
</div>