mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
Issue #464, "text gets muffled sometimes, in both TUI default and fullscreen". The screenshot is not a dropped frame or a frozen renderer — it is arithmetic. Claude Code's TUI wraps its frame at the width the PTY reported and erases the previous frame by walking the cursor up the rows it believes that frame took. A browser terminal of a different width makes each logical line occupy more physical rows than Ink counted, so `eraseLines(n)` clears too few and the new frame paints over rows nothing erased: doubled lines, and short tool summaries sitting inside longer prose rows with the prose's tail still visible. Reproduced against this repo's own xterm before changing anything — a 120-column PTY against a 62-column terminal renders every wrapped line twice. `test/ terminal-pty-geometry.test.ts` pins that, and pins the clean render at matching widths beside it, so the assertion cannot be satisfied by code that fixes nothing. Four ways the two drifted apart, none of them observable from either end: 1. `fitAddon.fit()` resizes xterm to `proposeDimensions()` RAW while every server-facing path reported those floored at 40x10. Measured in Chrome at 430px: font size 44 proposed 13 columns, the server was told 40, and xterm stayed at 13. Three call sites each did their own fit-then-floor, and two re-read the proposal after the fit — `_shrinkPaddingToFit()` runs exactly there, so the container had moved. 2. `throttledResize` (keyboard up) and `sendResize` (session detached into its own window) reflowed locally and withheld only the SIGWINCH. That is the one combination that cannot be right: a reflow nothing is rendering for buys nothing and costs correctness. Both now withhold everything, and the keyboard's settle timer still sends the one resize that stops the PTY going stale. 3. `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size — a geometry change — and told the server nothing at all, so raising the font on a phone left the CLI wrapping at the old column count. 4. `Session.resize` DECLINES a small-viewport request while a desktop connection holds an active sizing claim, and said nothing, because resize was write-only. `syncTerminalGeometry()` is now the one function that may change the terminal's size: it fits, floors and applies as a single step, so the numbers xterm holds are the numbers the server is told. A test sweeps every module for a bare `fit()` on the main terminal, and finds exactly one — the owner's own. For (4) the client cannot win, so it is told the truth instead: both transports answer a resize with `session.ptyCols`/`ptyRows` (`{"t":"zc"}` on the socket, the body of the resize POST) and `_onPtyGeometryReport` adopts them. A terminal that keeps a shape the PTY refused does not render "too narrow", it renders garbled. Adopting can leave the pane wider than the screen and the container is `overflow: hidden`, so `.pty-oversized` grants horizontal reach for exactly as long as the mismatch lasts: correct-and-reachable beats correct-and-clipped beats garbled. That rule sets both overflow axes and its own `touch-action` because mobile.css loads later and sets `.terminal-container { overflow: visible; touch-action: none }` — a bare `overflow-x` would leave overflow-y computing to `auto` and hand the browser a vertical scroll container the terminal's touch handler knows nothing about. Verified in Chrome at 430px against a live server, with a desktop client holding the claim: the phone adopts 198x43, gets `overflow-x: auto` / `overflow-y: hidden` / `touch-action: pan-x`, 758px of reach to the right, and keeps its own vertical scrolling. The pre-fix build was measured in the same harness for the control. Two things this deliberately does not do. It does not change who owns the pane size — the desktop still wins, and `_startMobileResizeRetry` still takes it back once that goes idle. And `throttledResize` still holds the PTY's shape for the whole keyboard animation rather than sending a SIGWINCH per step; that decision predates this and was not re-tested here. Also in this commit, Ark0N's third-pass review items on #431: - The response viewer's byte-buffer fallback and `_onSessionClearTerminal` both used the no-param `/terminal` form, capped only by `terminalBufferMaxBytes` (32MB) — the largest body the frontend asks for anywhere. One carried no deadline at all and the other got the 15s tail budget. Both now take the full-history budget. - A `?full=1` capture that outruns its deadline falls back to the bounded tail. The pane is blanked before that fetch, so an abort used to leave a black rectangle, discard the queued live output and never reach `_connectWs`. A failed load now still opens the socket, says one dim line where the content would have been, and clears the tab's spinner — which nothing did, so a failed select left `aria-busy="true"` set forever. - `_wsOutputGapSession` is cleared at the repaint that settles it, not in a `finally` that also ran on the catch. A reconcile that threw, or hit the new deadline — the flaky link the marker exists for — dropped the gap with nothing to retry it. `ws.onopen` no longer clears it up front either. - The replay-clear invariant is pinned in the gate, which is the drift this PR exists to fix: `_resetTerminalForReplay` must be a queued write and nothing else, and no module may blank the terminal with a `clear()+reset()` pair. - `DIAG_ENTRY_MAX_CHARS` replaces the hardcoded 300, bound through a local first: `CodemanDiag?.x` still throws a ReferenceError when the identifier was never declared, and that is the one function in the app that must not throw. - panels-ui's two kill-all clears route through the same helper, and the xterm-version guard's comment says "resolved lockfile version" rather than "dependency RANGE", which is what it has pinned since the last round. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1191 lines
40 KiB
JavaScript
1191 lines
40 KiB
JavaScript
/**
|
|
* @fileoverview Ralph state panel (progress ring, status, task cards, fix_plan.md integration),
|
|
* plan versioning (checkpoint, rollback, diff), and plan wizard agents in monitor.
|
|
* Includes 11 SSE handlers for Ralph and plan events.
|
|
*
|
|
* @mixin Extends CodemanApp.prototype via Object.assign
|
|
* @dependency app.js (CodemanApp class, this.ralphStates, this.activeSessionId)
|
|
* @dependency constants.js (escapeHtml)
|
|
* @loadorder 9 of 15 — loaded after respawn-ui.js, before settings-ui.js
|
|
*/
|
|
|
|
Object.assign(CodemanApp.prototype, {
|
|
// Ralph
|
|
_onRalphLoopUpdate(data) {
|
|
// Skip if user explicitly closed this session's Ralph panel
|
|
if (this.ralphClosedSessions.has(data.sessionId)) return;
|
|
this.updateRalphState(data.sessionId, { loop: data.state });
|
|
},
|
|
|
|
_onRalphTodoUpdate(data) {
|
|
// Skip if user explicitly closed this session's Ralph panel
|
|
if (this.ralphClosedSessions.has(data.sessionId)) return;
|
|
this.updateRalphState(data.sessionId, { todos: data.todos });
|
|
},
|
|
|
|
_onRalphCompletionDetected(data) {
|
|
// Skip if user explicitly closed this session's Ralph panel
|
|
if (this.ralphClosedSessions.has(data.sessionId)) return;
|
|
// Prevent duplicate notifications for the same completion
|
|
const completionKey = `${data.sessionId}:${data.phrase}`;
|
|
if (this._shownCompletions?.has(completionKey)) {
|
|
return;
|
|
}
|
|
if (!this._shownCompletions) {
|
|
this._shownCompletions = new Set();
|
|
}
|
|
this._shownCompletions.add(completionKey);
|
|
// Clear after 30 seconds to allow re-notification if loop restarts
|
|
setTimeout(() => this._shownCompletions?.delete(completionKey), 30000);
|
|
|
|
// Update ralph state to mark loop as inactive
|
|
const existing = this.ralphStates.get(data.sessionId) || {};
|
|
if (existing.loop) {
|
|
existing.loop.active = false;
|
|
this.updateRalphState(data.sessionId, existing);
|
|
}
|
|
|
|
this._notifySession(data.sessionId, 'warning', 'ralph-complete', 'Loop Complete', `Completion: ${data.phrase || 'unknown'}`);
|
|
},
|
|
|
|
_onRalphStatusUpdate(data) {
|
|
// Skip if user explicitly closed this session's Ralph panel
|
|
if (this.ralphClosedSessions.has(data.sessionId)) return;
|
|
this.updateRalphState(data.sessionId, { statusBlock: data.block });
|
|
},
|
|
|
|
_onCircuitBreakerUpdate(data) {
|
|
// Skip if user explicitly closed this session's Ralph panel
|
|
if (this.ralphClosedSessions.has(data.sessionId)) return;
|
|
this.updateRalphState(data.sessionId, { circuitBreaker: data.status });
|
|
// Notify if circuit breaker opens
|
|
if (data.status.state === 'OPEN') {
|
|
this._notifySession(data.sessionId, 'critical', 'circuit-breaker', 'Circuit Breaker Open', data.status.reason || 'Loop stuck - no progress detected');
|
|
}
|
|
},
|
|
|
|
_onExitGateMet(data) {
|
|
this._notifySession(data.sessionId, 'warning', 'exit-gate', 'Exit Gate Met', `Loop ready to exit (indicators: ${data.completionIndicators})`);
|
|
},
|
|
|
|
// Bash tools
|
|
|
|
// Plan orchestration
|
|
_onPlanSubagent(data) {
|
|
console.log('[Plan Subagent]', data);
|
|
this.handlePlanSubagentEvent(data);
|
|
},
|
|
|
|
_onPlanProgress(data) {
|
|
console.log('[Plan Progress]', data);
|
|
|
|
// Update UI if we have a progress handler registered
|
|
if (this._planProgressHandler) {
|
|
this._planProgressHandler({ type: 'plan:progress', data });
|
|
}
|
|
|
|
// Also update the loading display directly for better feedback
|
|
const titleEl = document.getElementById('planLoadingTitle');
|
|
const hintEl = document.getElementById('planLoadingHint');
|
|
|
|
if (titleEl && data.phase) {
|
|
const phaseLabels = {
|
|
'parallel-analysis': 'Running parallel analysis...',
|
|
'subagent': data.detail || 'Subagent working...',
|
|
'synthesis': 'Synthesizing results...',
|
|
'verification': 'Running verification...',
|
|
};
|
|
titleEl.textContent = phaseLabels[data.phase] || data.phase;
|
|
}
|
|
if (hintEl && data.detail) {
|
|
hintEl.textContent = data.detail;
|
|
}
|
|
},
|
|
|
|
_onPlanStarted(data) {
|
|
console.log('[Plan Started]', data);
|
|
this.activePlanOrchestratorId = data.orchestratorId;
|
|
this.planGenerationStopped = false; // Reset flag for new generation
|
|
this.renderMonitorPlanAgents();
|
|
},
|
|
|
|
_onPlanCancelled(data) {
|
|
console.log('[Plan Cancelled]', data);
|
|
if (this.activePlanOrchestratorId === data.orchestratorId) {
|
|
this.activePlanOrchestratorId = null;
|
|
}
|
|
this.renderMonitorPlanAgents();
|
|
},
|
|
|
|
_onPlanCompleted(data) {
|
|
console.log('[Plan Completed]', data);
|
|
if (this.activePlanOrchestratorId === data.orchestratorId) {
|
|
this.activePlanOrchestratorId = null;
|
|
}
|
|
this.renderMonitorPlanAgents();
|
|
},
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Enhanced Ralph Wiggum Loop Panel
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
updateRalphState(sessionId, updates) {
|
|
const existing = this.ralphStates.get(sessionId) || { loop: null, todos: [] };
|
|
const updated = { ...existing, ...updates };
|
|
this.ralphStates.set(sessionId, updated);
|
|
|
|
// Re-render if this is the active session
|
|
if (sessionId === this.activeSessionId) {
|
|
this.renderRalphStatePanel();
|
|
}
|
|
},
|
|
|
|
toggleRalphStatePanel() {
|
|
// Preserve xterm scroll position to prevent jump when panel height changes
|
|
const xtermViewport = this.terminal?.element?.querySelector('.xterm-viewport');
|
|
const scrollTop = xtermViewport?.scrollTop;
|
|
|
|
this.ralphStatePanelCollapsed = !this.ralphStatePanelCollapsed;
|
|
this.renderRalphStatePanel();
|
|
|
|
// Restore scroll position and refit terminal after layout change
|
|
requestAnimationFrame(() => {
|
|
// Restore xterm scroll position
|
|
if (xtermViewport && scrollTop !== undefined) {
|
|
xtermViewport.scrollTop = scrollTop;
|
|
}
|
|
// Refit terminal to new container size. Through the one owner so the
|
|
// floor that is reported to the PTY is also the one xterm holds (#464).
|
|
this.syncTerminalGeometry?.();
|
|
});
|
|
},
|
|
|
|
async closeRalphTracker() {
|
|
if (!this.activeSessionId) return;
|
|
|
|
// Mark this session as explicitly closed - will stay hidden until user re-enables
|
|
this.ralphClosedSessions.add(this.activeSessionId);
|
|
|
|
// Disable tracker via API
|
|
await this._apiPost(`/api/sessions/${this.activeSessionId}/ralph-config`, { enabled: false });
|
|
|
|
// Clear local state and hide panel
|
|
this.ralphStates.delete(this.activeSessionId);
|
|
this.renderRalphStatePanel();
|
|
},
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// @fix_plan.md Integration
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
toggleRalphMenu() {
|
|
const dropdown = document.getElementById('ralphDropdown');
|
|
if (dropdown) {
|
|
dropdown.classList.toggle('show');
|
|
}
|
|
},
|
|
|
|
closeRalphMenu() {
|
|
const dropdown = document.getElementById('ralphDropdown');
|
|
if (dropdown) {
|
|
dropdown.classList.remove('show');
|
|
}
|
|
},
|
|
|
|
async resetCircuitBreaker() {
|
|
if (!this.activeSessionId) return;
|
|
|
|
try {
|
|
const response = await this._apiPost(`/api/sessions/${this.activeSessionId}/ralph-circuit-breaker/reset`, {});
|
|
const data = await response?.json();
|
|
|
|
if (data?.success) {
|
|
this.notificationManager?.notify({
|
|
urgency: 'info',
|
|
category: 'circuit-breaker',
|
|
title: 'Reset',
|
|
message: 'Circuit breaker reset to CLOSED',
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error resetting circuit breaker:', error);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Generate @fix_plan.md content and show in a modal.
|
|
*/
|
|
async showFixPlan() {
|
|
if (!this.activeSessionId) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/sessions/${this.activeSessionId}/fix-plan`);
|
|
const data = await response.json();
|
|
|
|
if (!data.success) {
|
|
this.notificationManager?.notify({
|
|
urgency: 'error',
|
|
category: 'fix-plan',
|
|
title: 'Error',
|
|
message: data.error || 'Failed to generate fix plan',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Show in a modal
|
|
this.showFixPlanModal(data.data.content, data.data.todoCount);
|
|
} catch (error) {
|
|
console.error('Error fetching fix plan:', error);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Show fix plan content in a modal.
|
|
*/
|
|
showFixPlanModal(content, todoCount) {
|
|
// Create modal if it doesn't exist
|
|
let modal = document.getElementById('fixPlanModal');
|
|
if (!modal) {
|
|
modal = document.createElement('div');
|
|
modal.id = 'fixPlanModal';
|
|
modal.className = 'modal';
|
|
modal.innerHTML = `
|
|
<div class="modal-content fix-plan-modal">
|
|
<div class="modal-header">
|
|
<h3>@fix_plan.md</h3>
|
|
<button class="btn-close" onclick="app.closeFixPlanModal()">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<textarea id="fixPlanContent" class="fix-plan-textarea" readonly></textarea>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<span class="fix-plan-stats" id="fixPlanStats"></span>
|
|
<button class="btn btn-secondary" onclick="app.copyFixPlan()">Copy</button>
|
|
<button class="btn btn-primary" onclick="app.writeFixPlanToFile()">Write to File</button>
|
|
<button class="btn btn-secondary" onclick="app.closeFixPlanModal()">Close</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(modal);
|
|
}
|
|
|
|
document.getElementById('fixPlanContent').value = content;
|
|
document.getElementById('fixPlanStats').textContent = `${todoCount} tasks`;
|
|
modal.classList.add('show');
|
|
},
|
|
|
|
closeFixPlanModal() {
|
|
const modal = document.getElementById('fixPlanModal');
|
|
if (modal) {
|
|
modal.classList.remove('show');
|
|
}
|
|
},
|
|
|
|
async copyFixPlan() {
|
|
const content = document.getElementById('fixPlanContent')?.value;
|
|
if (content) {
|
|
await navigator.clipboard.writeText(content);
|
|
this.notificationManager?.notify({
|
|
urgency: 'info',
|
|
category: 'fix-plan',
|
|
title: 'Copied',
|
|
message: 'Fix plan copied to clipboard',
|
|
});
|
|
}
|
|
},
|
|
|
|
async writeFixPlanToFile() {
|
|
if (!this.activeSessionId) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/sessions/${this.activeSessionId}/fix-plan/write`, {
|
|
method: 'POST',
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
this.notificationManager?.notify({
|
|
urgency: 'info',
|
|
category: 'fix-plan',
|
|
title: 'Written',
|
|
message: `@fix_plan.md written to ${data.data.filePath}`,
|
|
});
|
|
this.closeFixPlanModal();
|
|
} else {
|
|
this.notificationManager?.notify({
|
|
urgency: 'error',
|
|
category: 'fix-plan',
|
|
title: 'Error',
|
|
message: data.error || 'Failed to write file',
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error writing fix plan:', error);
|
|
}
|
|
},
|
|
|
|
async importFixPlanFromFile() {
|
|
if (!this.activeSessionId) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/sessions/${this.activeSessionId}/fix-plan/read`, {
|
|
method: 'POST',
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
this.notificationManager?.notify({
|
|
urgency: 'info',
|
|
category: 'fix-plan',
|
|
title: 'Imported',
|
|
message: `Imported ${data.data.importedCount} tasks from @fix_plan.md`,
|
|
});
|
|
// Refresh ralph panel
|
|
this.updateRalphState(this.activeSessionId, { todos: data.data.todos });
|
|
} else {
|
|
this.notificationManager?.notify({
|
|
urgency: 'warning',
|
|
category: 'fix-plan',
|
|
title: 'Not Found',
|
|
message: data.error || '@fix_plan.md not found',
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error importing fix plan:', error);
|
|
}
|
|
},
|
|
|
|
toggleRalphDetach() {
|
|
const panel = this.$('ralphStatePanel');
|
|
const detachBtn = this.$('ralphDetachBtn');
|
|
|
|
if (!panel) return;
|
|
|
|
if (panel.classList.contains('detached')) {
|
|
// Re-attach to original position
|
|
panel.classList.remove('detached');
|
|
panel.style.top = '';
|
|
panel.style.left = '';
|
|
panel.style.width = '';
|
|
panel.style.height = '';
|
|
if (detachBtn) {
|
|
detachBtn.innerHTML = '⧉'; // Detach icon (two overlapping squares)
|
|
detachBtn.title = 'Detach panel';
|
|
}
|
|
} else {
|
|
// Detach as floating window
|
|
panel.classList.add('detached');
|
|
// Expand when detaching for better visibility
|
|
this.ralphStatePanelCollapsed = false;
|
|
panel.classList.remove('collapsed');
|
|
if (detachBtn) {
|
|
detachBtn.innerHTML = '⊞'; // Attach icon (squared plus - dock back)
|
|
detachBtn.title = 'Attach panel';
|
|
}
|
|
// Setup drag functionality
|
|
this.setupRalphDrag();
|
|
}
|
|
this.renderRalphStatePanel();
|
|
},
|
|
|
|
setupRalphDrag() {
|
|
const panel = this.$('ralphStatePanel');
|
|
const header = this.$('ralphSummary');
|
|
|
|
if (!panel || !header) return;
|
|
|
|
let isDragging = false;
|
|
let startX, startY, startLeft, startTop;
|
|
|
|
const onMouseDown = (e) => {
|
|
// Only drag from header, not from buttons or toggle
|
|
if (e.target.closest('button') || e.target.closest('.ralph-toggle')) return;
|
|
if (!panel.classList.contains('detached')) return;
|
|
|
|
isDragging = true;
|
|
startX = e.clientX;
|
|
startY = e.clientY;
|
|
const rect = panel.getBoundingClientRect();
|
|
startLeft = rect.left;
|
|
startTop = rect.top;
|
|
|
|
document.addEventListener('mousemove', onMouseMove);
|
|
document.addEventListener('mouseup', onMouseUp);
|
|
e.preventDefault();
|
|
};
|
|
|
|
const onMouseMove = (e) => {
|
|
if (!isDragging) return;
|
|
|
|
const dx = e.clientX - startX;
|
|
const dy = e.clientY - startY;
|
|
|
|
let newLeft = startLeft + dx;
|
|
let newTop = startTop + dy;
|
|
|
|
// Keep within viewport bounds
|
|
const rect = panel.getBoundingClientRect();
|
|
newLeft = Math.max(0, Math.min(window.innerWidth - rect.width, newLeft));
|
|
newTop = Math.max(0, Math.min(window.innerHeight - rect.height, newTop));
|
|
|
|
panel.style.left = newLeft + 'px';
|
|
panel.style.top = newTop + 'px';
|
|
};
|
|
|
|
const onMouseUp = () => {
|
|
isDragging = false;
|
|
document.removeEventListener('mousemove', onMouseMove);
|
|
document.removeEventListener('mouseup', onMouseUp);
|
|
};
|
|
|
|
// Remove existing listeners before adding new ones
|
|
header.removeEventListener('mousedown', header._ralphDragHandler);
|
|
header._ralphDragHandler = onMouseDown;
|
|
header.addEventListener('mousedown', onMouseDown);
|
|
},
|
|
|
|
renderRalphStatePanel() {
|
|
this._debouncedCall('ralphStatePanel', this._renderRalphStatePanelImmediate, 50);
|
|
},
|
|
|
|
_renderRalphStatePanelImmediate() {
|
|
const panel = this.$('ralphStatePanel');
|
|
const toggle = this.$('ralphToggle');
|
|
|
|
if (!panel) return;
|
|
|
|
// If user explicitly closed this session's Ralph panel, keep it hidden
|
|
if (this.ralphClosedSessions.has(this.activeSessionId)) {
|
|
panel.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
const state = this.ralphStates.get(this.activeSessionId);
|
|
|
|
// Check if there's anything to show
|
|
// Only show panel if tracker is enabled OR there's active state to display
|
|
const isEnabled = state?.loop?.enabled === true;
|
|
const hasLoop = state?.loop?.active || state?.loop?.completionPhrase;
|
|
const hasTodos = state?.todos?.length > 0;
|
|
const hasCircuitBreaker = state?.circuitBreaker && state.circuitBreaker.state !== 'CLOSED';
|
|
const hasStatusBlock = state?.statusBlock !== undefined;
|
|
|
|
if (!isEnabled && !hasLoop && !hasTodos && !hasCircuitBreaker && !hasStatusBlock) {
|
|
panel.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
panel.style.display = '';
|
|
|
|
// Calculate completion percentage
|
|
const todos = state?.todos || [];
|
|
const completed = todos.filter(t => t.status === 'completed').length;
|
|
const total = todos.length;
|
|
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
|
|
|
// Update progress rings
|
|
this.updateRalphRing(percent);
|
|
|
|
// Update status badge (pass completion info)
|
|
this.updateRalphStatus(state?.loop, completed, total);
|
|
|
|
// Update stats
|
|
this.updateRalphStats(state?.loop, completed, total);
|
|
|
|
// Update circuit breaker badge
|
|
this.updateCircuitBreakerBadge(state?.circuitBreaker);
|
|
|
|
// Handle collapsed/expanded state
|
|
if (this.ralphStatePanelCollapsed) {
|
|
panel.classList.add('collapsed');
|
|
if (toggle) toggle.innerHTML = '▼'; // Down arrow when collapsed (click to expand)
|
|
} else {
|
|
panel.classList.remove('collapsed');
|
|
if (toggle) toggle.innerHTML = '▲'; // Up arrow when expanded (click to collapse)
|
|
|
|
// Update expanded view content
|
|
this.updateRalphExpandedView(state);
|
|
}
|
|
},
|
|
|
|
updateRalphRing(percent) {
|
|
// Ensure percent is a valid number between 0-100
|
|
const safePercent = Math.max(0, Math.min(100, Number(percent) || 0));
|
|
|
|
// Mini ring (in summary)
|
|
const miniProgress = this.$('ralphRingMiniProgress');
|
|
const miniText = this.$('ralphRingMiniText');
|
|
if (miniProgress) {
|
|
// Circumference = 2 * PI * r = 2 * PI * 15.9 ≈ 100
|
|
// offset = 100 means 0% visible, offset = 0 means 100% visible
|
|
const offset = 100 - safePercent;
|
|
miniProgress.style.strokeDashoffset = offset;
|
|
}
|
|
if (miniText) {
|
|
miniText.textContent = `${safePercent}%`;
|
|
}
|
|
|
|
// Large ring (in expanded view)
|
|
const largeProgress = this.$('ralphRingProgress');
|
|
const largePercent = this.$('ralphRingPercent');
|
|
if (largeProgress) {
|
|
// Circumference = 2 * PI * r = 2 * PI * 42 ≈ 264
|
|
// offset = 264 means 0% visible, offset = 0 means 100% visible
|
|
const offset = 264 - (264 * safePercent / 100);
|
|
largeProgress.style.strokeDashoffset = offset;
|
|
}
|
|
if (largePercent) {
|
|
largePercent.textContent = `${safePercent}%`;
|
|
}
|
|
},
|
|
|
|
updateRalphStatus(loop, completed = 0, total = 0) {
|
|
const badge = this.$('ralphStatusBadge');
|
|
const statusText = badge?.querySelector('.ralph-status-text');
|
|
if (!badge || !statusText) return;
|
|
|
|
badge.classList.remove('active', 'completed', 'tracking');
|
|
|
|
if (loop?.active) {
|
|
badge.classList.add('active');
|
|
statusText.textContent = 'Running';
|
|
} else if (total > 0 && completed === total) {
|
|
// Only show "Complete" when all todos are actually done
|
|
badge.classList.add('completed');
|
|
statusText.textContent = 'Complete';
|
|
} else if (loop?.enabled || total > 0) {
|
|
badge.classList.add('tracking');
|
|
statusText.textContent = 'Tracking';
|
|
} else {
|
|
statusText.textContent = 'Idle';
|
|
}
|
|
},
|
|
|
|
updateCircuitBreakerBadge(circuitBreaker) {
|
|
// Find or create the circuit breaker badge container
|
|
let cbContainer = this.$('ralphCircuitBreakerBadge');
|
|
if (!cbContainer) {
|
|
// Create container if it doesn't exist (we'll add it dynamically)
|
|
const summary = this.$('ralphSummary');
|
|
if (!summary) return;
|
|
|
|
// Check if it already exists
|
|
cbContainer = summary.querySelector('.ralph-circuit-breaker');
|
|
if (!cbContainer) {
|
|
cbContainer = document.createElement('div');
|
|
cbContainer.id = 'ralphCircuitBreakerBadge';
|
|
cbContainer.className = 'ralph-circuit-breaker';
|
|
// Insert after the status badge
|
|
const statusBadge = this.$('ralphStatusBadge');
|
|
if (statusBadge && statusBadge.nextSibling) {
|
|
statusBadge.parentNode.insertBefore(cbContainer, statusBadge.nextSibling);
|
|
} else {
|
|
summary.appendChild(cbContainer);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Hide if no circuit breaker state or CLOSED
|
|
if (!circuitBreaker || circuitBreaker.state === 'CLOSED') {
|
|
cbContainer.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
cbContainer.style.display = '';
|
|
cbContainer.classList.remove('half-open', 'open');
|
|
|
|
if (circuitBreaker.state === 'HALF_OPEN') {
|
|
cbContainer.classList.add('half-open');
|
|
cbContainer.innerHTML = `<span class="cb-icon">⚠</span><span class="cb-text">Warning</span>`;
|
|
cbContainer.title = circuitBreaker.reason || 'Circuit breaker warning';
|
|
} else if (circuitBreaker.state === 'OPEN') {
|
|
cbContainer.classList.add('open');
|
|
cbContainer.innerHTML = `<span class="cb-icon">🛑</span><span class="cb-text">Stuck</span>`;
|
|
cbContainer.title = circuitBreaker.reason || 'Loop appears stuck';
|
|
}
|
|
|
|
// Add click handler to reset
|
|
cbContainer.onclick = () => this.resetCircuitBreaker();
|
|
},
|
|
|
|
|
|
updateRalphStats(loop, completed, total) {
|
|
// Time stat
|
|
const timeEl = this.$('ralphStatTime');
|
|
if (timeEl) {
|
|
if (loop?.elapsedHours !== null && loop?.elapsedHours !== undefined) {
|
|
timeEl.textContent = this.formatRalphTime(loop.elapsedHours);
|
|
} else if (loop?.startedAt) {
|
|
const hours = (Date.now() - loop.startedAt) / (1000 * 60 * 60);
|
|
timeEl.textContent = this.formatRalphTime(hours);
|
|
} else {
|
|
timeEl.textContent = '0m';
|
|
}
|
|
}
|
|
|
|
// Cycles stat
|
|
const cyclesEl = this.$('ralphStatCycles');
|
|
if (cyclesEl) {
|
|
if (loop?.maxIterations) {
|
|
cyclesEl.textContent = `${loop.cycleCount || 0}/${loop.maxIterations}`;
|
|
} else {
|
|
cyclesEl.textContent = String(loop?.cycleCount || 0);
|
|
}
|
|
}
|
|
|
|
// Tasks stat
|
|
const tasksEl = this.$('ralphStatTasks');
|
|
if (tasksEl) {
|
|
tasksEl.textContent = `${completed}/${total}`;
|
|
}
|
|
},
|
|
|
|
formatRalphTime(hours) {
|
|
if (hours < 0.0167) return '0m'; // < 1 minute
|
|
if (hours < 1) {
|
|
const minutes = Math.round(hours * 60);
|
|
return `${minutes}m`;
|
|
}
|
|
const h = Math.floor(hours);
|
|
const m = Math.round((hours - h) * 60);
|
|
if (m === 0) return `${h}h`;
|
|
return `${h}h ${m}m`;
|
|
},
|
|
|
|
updateRalphExpandedView(state) {
|
|
// Update phrase
|
|
const phraseEl = this.$('ralphPhrase');
|
|
if (phraseEl) {
|
|
phraseEl.textContent = state?.loop?.completionPhrase || '--';
|
|
}
|
|
|
|
// Update elapsed
|
|
const elapsedEl = this.$('ralphElapsed');
|
|
if (elapsedEl) {
|
|
if (state?.loop?.elapsedHours !== null && state?.loop?.elapsedHours !== undefined) {
|
|
elapsedEl.textContent = this.formatRalphTime(state.loop.elapsedHours);
|
|
} else if (state?.loop?.startedAt) {
|
|
const hours = (Date.now() - state.loop.startedAt) / (1000 * 60 * 60);
|
|
elapsedEl.textContent = this.formatRalphTime(hours);
|
|
} else {
|
|
elapsedEl.textContent = '0m';
|
|
}
|
|
}
|
|
|
|
// Update iterations
|
|
const iterationsEl = this.$('ralphIterations');
|
|
if (iterationsEl) {
|
|
if (state?.loop?.maxIterations) {
|
|
iterationsEl.textContent = `${state.loop.cycleCount || 0} / ${state.loop.maxIterations}`;
|
|
} else {
|
|
iterationsEl.textContent = String(state?.loop?.cycleCount || 0);
|
|
}
|
|
}
|
|
|
|
// Update tasks count
|
|
const todos = state?.todos || [];
|
|
const completed = todos.filter(t => t.status === 'completed').length;
|
|
const tasksCountEl = this.$('ralphTasksCount');
|
|
if (tasksCountEl) {
|
|
tasksCountEl.textContent = `${completed}/${todos.length}`;
|
|
}
|
|
|
|
// Update plan version display if available
|
|
if (state?.loop?.planVersion) {
|
|
this.updatePlanVersionDisplay(state.loop.planVersion, state.loop.planHistoryLength || 1);
|
|
} else {
|
|
this.updatePlanVersionDisplay(null, 0);
|
|
}
|
|
|
|
// Render task cards
|
|
this.renderRalphTasks(todos);
|
|
|
|
// Render RALPH_STATUS block if present
|
|
this.renderRalphStatusBlock(state?.statusBlock);
|
|
},
|
|
|
|
renderRalphStatusBlock(statusBlock) {
|
|
// Find or create the status block container
|
|
let container = this.$('ralphStatusBlockDisplay');
|
|
const expandedContent = this.$('ralphExpandedContent');
|
|
|
|
if (!statusBlock) {
|
|
// Remove container if no status block
|
|
if (container) {
|
|
container.remove();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!container && expandedContent) {
|
|
container = document.createElement('div');
|
|
container.id = 'ralphStatusBlockDisplay';
|
|
container.className = 'ralph-status-block';
|
|
// Insert at the top of expanded content
|
|
expandedContent.insertBefore(container, expandedContent.firstChild);
|
|
}
|
|
|
|
if (!container) return;
|
|
|
|
// Build status class
|
|
const statusClass = statusBlock.status === 'IN_PROGRESS' ? 'in-progress'
|
|
: statusBlock.status === 'COMPLETE' ? 'complete'
|
|
: statusBlock.status === 'BLOCKED' ? 'blocked' : '';
|
|
|
|
// Build tests status icon
|
|
const testsIcon = statusBlock.testsStatus === 'PASSING' ? '✅'
|
|
: statusBlock.testsStatus === 'FAILING' ? '❌'
|
|
: '⏸';
|
|
|
|
// Build work type icon
|
|
const workIcon = statusBlock.workType === 'IMPLEMENTATION' ? '🔧'
|
|
: statusBlock.workType === 'TESTING' ? '🧪'
|
|
: statusBlock.workType === 'DOCUMENTATION' ? '📝'
|
|
: statusBlock.workType === 'REFACTORING' ? '♻️' : '📋';
|
|
|
|
let html = `
|
|
<div class="ralph-status-block-header">
|
|
<span>RALPH_STATUS</span>
|
|
<span class="ralph-status-block-status ${statusClass}">${escapeHtml(statusBlock.status)}</span>
|
|
${statusBlock.exitSignal ? '<span style="color: #4caf50;">🚪 EXIT</span>' : ''}
|
|
</div>
|
|
<div class="ralph-status-block-stats">
|
|
<span>${workIcon} ${escapeHtml(statusBlock.workType)}</span>
|
|
<span>📁 ${statusBlock.filesModified} files</span>
|
|
<span>✓ ${escapeHtml(String(statusBlock.tasksCompletedThisLoop))} tasks</span>
|
|
<span>${testsIcon} Tests: ${escapeHtml(statusBlock.testsStatus)}</span>
|
|
</div>
|
|
`;
|
|
|
|
if (statusBlock.recommendation) {
|
|
html += `<div class="ralph-status-block-recommendation">${escapeHtml(statusBlock.recommendation)}</div>`;
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
},
|
|
|
|
renderRalphTasks(todos) {
|
|
const grid = this.$('ralphTasksGrid');
|
|
if (!grid) return;
|
|
|
|
if (todos.length === 0) {
|
|
if (grid.children.length !== 1 || !grid.querySelector('.ralph-state-empty')) {
|
|
grid.innerHTML = '<div class="ralph-state-empty">No tasks detected</div>';
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Sort: by priority (P0 > P1 > P2 > null), then by status (in_progress > pending > completed)
|
|
const priorityOrder = { 'P0': 0, 'P1': 1, 'P2': 2, null: 3 };
|
|
const statusOrder = { in_progress: 0, pending: 1, completed: 2 };
|
|
const sorted = [...todos].sort((a, b) => {
|
|
const priA = priorityOrder[a.priority] ?? 3;
|
|
const priB = priorityOrder[b.priority] ?? 3;
|
|
if (priA !== priB) return priA - priB;
|
|
return (statusOrder[a.status] || 1) - (statusOrder[b.status] || 1);
|
|
});
|
|
|
|
// Always do full rebuild for enhanced features
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
sorted.forEach((todo, idx) => {
|
|
const card = this.createRalphTaskCard(todo, idx);
|
|
fragment.appendChild(card);
|
|
});
|
|
|
|
grid.innerHTML = '';
|
|
grid.appendChild(fragment);
|
|
},
|
|
|
|
createRalphTaskCard(todo, index) {
|
|
const card = document.createElement('div');
|
|
const statusClass = `task-${todo.status.replace('_', '-')}`;
|
|
const priorityClass = todo.priority ? `task-priority-${todo.priority.toLowerCase()}` : '';
|
|
card.className = `ralph-task-card ${statusClass} ${priorityClass}`.trim();
|
|
card.dataset.taskId = todo.id || index;
|
|
|
|
// Status icon
|
|
const iconSpan = document.createElement('span');
|
|
iconSpan.className = 'ralph-task-icon';
|
|
iconSpan.textContent = this.getRalphTaskIcon(todo.status);
|
|
card.appendChild(iconSpan);
|
|
|
|
// Priority badge if present
|
|
if (todo.priority) {
|
|
const prioritySpan = document.createElement('span');
|
|
prioritySpan.className = `ralph-task-priority priority-${todo.priority.toLowerCase()}`;
|
|
prioritySpan.textContent = todo.priority;
|
|
card.appendChild(prioritySpan);
|
|
}
|
|
|
|
// Task content
|
|
const contentSpan = document.createElement('span');
|
|
contentSpan.className = 'ralph-task-content';
|
|
contentSpan.textContent = todo.content;
|
|
card.appendChild(contentSpan);
|
|
|
|
// Attempts indicator (if > 0)
|
|
if (todo.attempts && todo.attempts > 0) {
|
|
const attemptsSpan = document.createElement('span');
|
|
attemptsSpan.className = 'ralph-task-attempts';
|
|
if (todo.lastError) {
|
|
attemptsSpan.classList.add('has-errors');
|
|
attemptsSpan.title = `Last error: ${todo.lastError}`;
|
|
}
|
|
attemptsSpan.textContent = `#${todo.attempts}`;
|
|
card.appendChild(attemptsSpan);
|
|
}
|
|
|
|
// Verification badge (if has verification criteria)
|
|
if (todo.verificationCriteria) {
|
|
const verifySpan = document.createElement('span');
|
|
verifySpan.className = 'ralph-task-verify-badge';
|
|
verifySpan.title = `Verify: ${todo.verificationCriteria}`;
|
|
verifySpan.textContent = '✓';
|
|
card.appendChild(verifySpan);
|
|
}
|
|
|
|
// Dependencies indicator
|
|
if (todo.dependencies && todo.dependencies.length > 0) {
|
|
const depsSpan = document.createElement('span');
|
|
depsSpan.className = 'ralph-task-deps-indicator';
|
|
depsSpan.title = `Depends on: ${todo.dependencies.join(', ')}`;
|
|
depsSpan.textContent = `↗${todo.dependencies.length}`;
|
|
card.appendChild(depsSpan);
|
|
}
|
|
|
|
// Quick action buttons (shown on hover)
|
|
const actions = document.createElement('div');
|
|
actions.className = 'ralph-task-actions';
|
|
|
|
if (todo.status !== 'completed') {
|
|
const completeBtn = document.createElement('button');
|
|
completeBtn.className = 'ralph-task-action-btn';
|
|
completeBtn.textContent = '✓';
|
|
completeBtn.title = 'Mark complete';
|
|
completeBtn.onclick = (e) => {
|
|
e.stopPropagation();
|
|
this.updateRalphTaskStatus(todo.id, 'completed');
|
|
};
|
|
actions.appendChild(completeBtn);
|
|
}
|
|
|
|
if (todo.status === 'completed') {
|
|
const reopenBtn = document.createElement('button');
|
|
reopenBtn.className = 'ralph-task-action-btn';
|
|
reopenBtn.textContent = '↺';
|
|
reopenBtn.title = 'Reopen';
|
|
reopenBtn.onclick = (e) => {
|
|
e.stopPropagation();
|
|
this.updateRalphTaskStatus(todo.id, 'pending');
|
|
};
|
|
actions.appendChild(reopenBtn);
|
|
}
|
|
|
|
if (todo.lastError) {
|
|
const retryBtn = document.createElement('button');
|
|
retryBtn.className = 'ralph-task-action-btn';
|
|
retryBtn.textContent = '↻';
|
|
retryBtn.title = 'Retry (clear error)';
|
|
retryBtn.onclick = (e) => {
|
|
e.stopPropagation();
|
|
this.retryRalphTask(todo.id);
|
|
};
|
|
actions.appendChild(retryBtn);
|
|
}
|
|
|
|
card.appendChild(actions);
|
|
|
|
return card;
|
|
},
|
|
|
|
// Update a Ralph task's status via API
|
|
async updateRalphTaskStatus(taskId, newStatus) {
|
|
if (!this.activeSessionId) return;
|
|
|
|
try {
|
|
const res = await fetch(`/api/sessions/${this.activeSessionId}/plan/task/${taskId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ status: newStatus })
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json();
|
|
throw new Error(data.error || 'Failed to update task');
|
|
}
|
|
|
|
this.showToast(`Task ${newStatus === 'completed' ? 'completed' : 'reopened'}`, 'success');
|
|
} catch (err) {
|
|
this.showToast('Failed to update task: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
// Retry a failed Ralph task (clear error, reset attempts)
|
|
async retryRalphTask(taskId) {
|
|
if (!this.activeSessionId) return;
|
|
|
|
try {
|
|
const res = await fetch(`/api/sessions/${this.activeSessionId}/plan/task/${taskId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ attempts: 0, lastError: null, status: 'pending' })
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json();
|
|
throw new Error(data.error || 'Failed to retry task');
|
|
}
|
|
|
|
this.showToast('Task reset for retry', 'success');
|
|
} catch (err) {
|
|
this.showToast('Failed to retry task: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
getRalphTaskIcon(status) {
|
|
switch (status) {
|
|
case 'completed': return '✓';
|
|
case 'in_progress': return '◐';
|
|
case 'pending':
|
|
default: return '○';
|
|
}
|
|
},
|
|
|
|
// Legacy method for backwards compatibility
|
|
getTodoIcon(status) {
|
|
return this.getRalphTaskIcon(status);
|
|
},
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Plan Versioning
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
// Update the plan version display in the Ralph panel
|
|
updatePlanVersionDisplay(version, historyLength) {
|
|
const versionRow = this.$('ralphVersionRow');
|
|
const versionBadge = this.$('ralphPlanVersion');
|
|
const rollbackBtn = this.$('ralphRollbackBtn');
|
|
|
|
if (!versionRow) return;
|
|
|
|
if (version && version > 0) {
|
|
versionRow.style.display = '';
|
|
if (versionBadge) versionBadge.textContent = `v${version}`;
|
|
if (rollbackBtn) {
|
|
rollbackBtn.style.display = historyLength > 1 ? '' : 'none';
|
|
}
|
|
} else {
|
|
versionRow.style.display = 'none';
|
|
}
|
|
},
|
|
|
|
// Show plan history dropdown
|
|
async showPlanHistory() {
|
|
if (!this.activeSessionId) return;
|
|
|
|
try {
|
|
const res = await fetch(`/api/sessions/${this.activeSessionId}/plan/history`);
|
|
const data = await res.json();
|
|
|
|
if (data.error) {
|
|
this.showToast('Failed to load plan history: ' + data.error, 'error');
|
|
return;
|
|
}
|
|
|
|
const history = data.data.history || [];
|
|
if (history.length === 0) {
|
|
this.showToast('No plan history available', 'info');
|
|
return;
|
|
}
|
|
|
|
// Show history dropdown modal
|
|
this.showPlanHistoryModal(history, data.data.currentVersion);
|
|
} catch (err) {
|
|
this.showToast('Failed to load plan history: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
// Show the plan history modal
|
|
showPlanHistoryModal(history, currentVersion) {
|
|
// Remove existing modal if present
|
|
const existing = document.getElementById('planHistoryModal');
|
|
if (existing) existing.remove();
|
|
|
|
const modal = document.createElement('div');
|
|
modal.id = 'planHistoryModal';
|
|
modal.className = 'modal active';
|
|
modal.innerHTML = `
|
|
<div class="modal-backdrop" onclick="app.closePlanHistoryModal()"></div>
|
|
<div class="modal-content modal-sm">
|
|
<div class="modal-header">
|
|
<h3>Plan Version History</h3>
|
|
<button class="modal-close" onclick="app.closePlanHistoryModal()">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p style="font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.75rem;">
|
|
Current version: <strong>v${currentVersion}</strong>
|
|
</p>
|
|
<div class="plan-history-list">
|
|
${history.map(item => `
|
|
<div class="plan-history-item ${item.version === currentVersion ? 'current' : ''}"
|
|
onclick="app.rollbackToPlanVersion(${item.version})">
|
|
<div>
|
|
<span class="plan-history-version">v${item.version}</span>
|
|
<span class="plan-history-tasks">${item.stats?.total ?? 0} tasks</span>
|
|
</div>
|
|
<span class="plan-history-time">${this.formatRelativeTime(item.timestamp)}</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn-toolbar" onclick="app.closePlanHistoryModal()">Close</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(modal);
|
|
},
|
|
|
|
closePlanHistoryModal() {
|
|
const modal = document.getElementById('planHistoryModal');
|
|
if (modal) modal.remove();
|
|
},
|
|
|
|
// Rollback to a specific plan version
|
|
async rollbackToPlanVersion(version) {
|
|
if (!this.activeSessionId) return;
|
|
|
|
if (!confirm(`Rollback to plan version ${version}? Current changes will be preserved in history.`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/sessions/${this.activeSessionId}/plan/rollback/${version}`, {
|
|
method: 'POST'
|
|
});
|
|
const data = await res.json();
|
|
|
|
if (data.error) {
|
|
this.showToast('Failed to rollback: ' + data.error, 'error');
|
|
return;
|
|
}
|
|
|
|
this.showToast(`Rolled back to plan v${version}`, 'success');
|
|
this.closePlanHistoryModal();
|
|
|
|
// Refresh the plan display
|
|
this.renderRalphStatePanel();
|
|
} catch (err) {
|
|
this.showToast('Failed to rollback: ' + err.message, 'error');
|
|
}
|
|
},
|
|
|
|
// Format relative time (e.g., "2 mins ago", "1 hour ago")
|
|
formatRelativeTime(timestamp) {
|
|
if (!timestamp) return '';
|
|
|
|
const now = Date.now();
|
|
const diff = now - timestamp;
|
|
|
|
const mins = Math.floor(diff / 60000);
|
|
const hours = Math.floor(diff / 3600000);
|
|
const days = Math.floor(diff / 86400000);
|
|
|
|
if (mins < 1) return 'just now';
|
|
if (mins < 60) return `${mins}m ago`;
|
|
if (hours < 24) return `${hours}h ago`;
|
|
return `${days}d ago`;
|
|
},
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Plan Wizard Agents in Monitor
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
renderMonitorPlanAgents() {
|
|
const section = document.getElementById('monitorPlanAgentsSection');
|
|
const body = document.getElementById('monitorPlanAgentsBody');
|
|
const stats = document.getElementById('monitorPlanAgentStats');
|
|
if (!section || !body) return;
|
|
|
|
const planAgents = Array.from(this.planSubagents?.values() || []);
|
|
const hasActiveOrchestrator = !!this.activePlanOrchestratorId;
|
|
|
|
// Show section only if there are plan agents or active orchestrator
|
|
if (planAgents.length === 0 && !hasActiveOrchestrator) {
|
|
section.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
section.style.display = '';
|
|
|
|
const activeCount = planAgents.filter(a => a.status === 'running').length;
|
|
const completedCount = planAgents.filter(a => a.status === 'completed' || a.status === 'failed').length;
|
|
|
|
if (stats) {
|
|
if (hasActiveOrchestrator) {
|
|
stats.textContent = `${activeCount} running, ${completedCount} done`;
|
|
} else {
|
|
stats.textContent = `${planAgents.length} total`;
|
|
}
|
|
}
|
|
|
|
if (planAgents.length === 0) {
|
|
body.innerHTML = `<div class="monitor-empty">${hasActiveOrchestrator ? 'Plan generation starting...' : 'No plan agents'}</div>`;
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
for (const agent of planAgents) {
|
|
const statusClass = agent.status === 'running' ? 'active' : agent.status === 'completed' ? 'completed' : 'error';
|
|
const agentLabel = agent.agentType || agent.agentId;
|
|
const modelBadge = agent.model ? `<span class="model-badge opus">opus</span>` : '';
|
|
const detail = agent.detail ? escapeHtml(agent.detail.substring(0, 50)) : '';
|
|
const duration = agent.durationMs ? `${(agent.durationMs / 1000).toFixed(1)}s` : '';
|
|
const itemCount = agent.itemCount ? `${agent.itemCount} items` : '';
|
|
|
|
html += `
|
|
<div class="process-item">
|
|
<span class="process-mode ${statusClass}">${agent.status || 'pending'}</span>
|
|
<div class="process-info">
|
|
<div class="process-name">${modelBadge} ${escapeHtml(agentLabel)}</div>
|
|
<div class="process-meta">
|
|
${detail ? `<span>${detail}</span>` : ''}
|
|
${itemCount ? `<span>${itemCount}</span>` : ''}
|
|
${duration ? `<span>${duration}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
body.innerHTML = html;
|
|
},
|
|
|
|
async cancelPlanFromMonitor() {
|
|
if (!this.activePlanOrchestratorId && this.planSubagents?.size === 0) {
|
|
this.showToast('No active plan generation', 'info');
|
|
return;
|
|
}
|
|
|
|
if (!confirm('Cancel plan generation and close all plan agent windows?')) return;
|
|
|
|
// Cancel the plan generation (reuse existing method)
|
|
await this.cancelPlanGeneration();
|
|
|
|
// Also force close the wizard if it's open
|
|
const wizardModal = document.getElementById('ralphWizardModal');
|
|
if (wizardModal?.classList.contains('active')) {
|
|
this.closeRalphWizard();
|
|
}
|
|
|
|
// Update monitor display
|
|
this.renderMonitorPlanAgents();
|
|
this.showToast('Plan generation cancelled', 'success');
|
|
},
|
|
});
|