Files
echo-core/dashboard/workspace.html
Marius Mutu 608668d8a6 fix(dashboard): replace broken Rulează Ralph on idle cards with Propose
The idle-state action called /api/projects/approve, which 404'd because
idle workspace dirs have no approved-tasks.json entry to mutate. Now the
button opens the Propose modal pre-filled with the workspace slug so the
user actually has a path forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:48:01 +00:00

2405 lines
101 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="/echo/favicon.svg">
<title>Echo · Workspace</title>
<link rel="stylesheet" href="/echo/static/tokens.css">
<link rel="stylesheet" href="/echo/common.css">
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<script src="/echo/swipe-nav.js"></script>
<!-- Markdown renderer for planning chat (loaded for assistant messages) -->
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.0/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.6/dist/purify.min.js"></script>
<style>
/* ──────────────────────────────────────────
Layout
────────────────────────────────────────── */
.main {
max-width: 1400px;
margin: 0 auto;
padding: var(--space-5);
}
.ws-header {
position: sticky;
top: 56px;
z-index: 50;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-4) 0;
margin-bottom: var(--space-4);
background: var(--bg-base);
border-bottom: 1px solid var(--border);
}
.ws-title-block {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.ws-title {
font-size: var(--text-xl);
font-weight: 600;
color: var(--text-primary);
}
.ws-count {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: var(--radius-full);
background: var(--bg-surface);
color: var(--text-muted);
font-size: var(--text-xs);
font-weight: 500;
}
.ws-toolbar {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
/* ──────────────────────────────────────────
SSE indicator (Live / Polling / Offline)
────────────────────────────────────────── */
.live-indicator {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-3);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-full);
font-size: var(--text-xs);
color: var(--text-secondary);
cursor: pointer;
user-select: none;
transition: background var(--transition-fast);
}
.live-indicator:hover {
background: var(--bg-surface-hover);
}
.live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
}
.live-indicator[data-mode="live"] .live-dot {
background: var(--success);
animation: pulse-dot 2s ease-in-out infinite;
}
.live-indicator[data-mode="polling"] .live-dot {
background: var(--warning);
animation: none;
}
.live-indicator[data-mode="offline"] .live-dot {
background: var(--error);
animation: none;
}
.live-indicator[data-mode="connecting"] .live-dot {
background: var(--text-muted);
animation: none;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.55; transform: scale(1.25); }
}
/* Compact mode for narrow viewports */
@media (max-width: 640px) {
.ws-toolbar { gap: var(--space-1); }
.live-indicator-text { display: none; }
.live-indicator { padding: var(--space-1) var(--space-2); }
}
/* ──────────────────────────────────────────
Cards grid
────────────────────────────────────────── */
.projects-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-4);
}
@media (max-width: 1024px) {
.projects-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
.projects-grid { grid-template-columns: 1fr; }
}
.project-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
min-height: 200px;
transition: border-color var(--transition-fast), background var(--transition-fast);
}
.project-card:hover {
border-color: var(--border-focus);
}
.project-card[data-status="planning"] {
border-color: rgba(167, 139, 250, 0.6);
box-shadow: 0 0 0 1px rgba(167, 139, 250, 0.25);
}
.project-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.project-slug {
font-family: var(--font-mono);
font-weight: 600;
font-size: var(--text-base);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ──────────────────────────────────────────
Status pill
────────────────────────────────────────── */
.status-pill {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 2px 10px;
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 600;
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.pulse-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
animation: pulse-dot 2s ease-in-out infinite;
}
.status-pill[data-status="running-ralph"] { background: rgba(34, 197, 94, 0.18); color: var(--status-running); }
.status-pill[data-status="running-manual"] { background: rgba(34, 197, 94, 0.18); color: var(--status-running); }
.status-pill[data-status="planning"] { background: rgba(167, 139, 250, 0.20); color: var(--status-planning); }
.status-pill[data-status="approved"] { background: rgba(234, 179, 8, 0.18); color: var(--status-approved); }
.status-pill[data-status="pending"] { background: rgba(96, 165, 250, 0.20); color: var(--status-pending); }
.status-pill[data-status="blocked"] { background: rgba(245, 158, 11, 0.18); color: var(--status-blocked); }
.status-pill[data-status="failed"] { background: rgba(239, 68, 68, 0.18); color: var(--status-failed); }
.status-pill[data-status="complete"] { background: rgba(156, 163, 175, 0.18); color: var(--status-complete); }
.status-pill[data-status="idle"] { background: var(--bg-surface-active); color: var(--status-idle); }
/* ──────────────────────────────────────────
Card body content
────────────────────────────────────────── */
.project-card-body {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-2);
font-size: var(--text-sm);
color: var(--text-secondary);
}
.project-meta {
font-size: var(--text-xs);
color: var(--text-muted);
line-height: 1.5;
}
.project-description {
font-size: var(--text-sm);
color: var(--text-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
.project-preview {
font-size: var(--text-xs);
color: var(--text-muted);
font-style: italic;
line-height: 1.4;
border-left: 2px solid rgba(167, 139, 250, 0.4);
padding-left: var(--space-2);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.project-error {
font-size: var(--text-xs);
color: var(--status-failed);
line-height: 1.4;
}
/* ──────────────────────────────────────────
Progress bar + tags
────────────────────────────────────────── */
.progress-row {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.progress-meta {
display: flex;
justify-content: space-between;
font-size: var(--text-xs);
color: var(--text-muted);
}
.progress-bar {
height: 6px;
background: var(--bg-surface-active);
border-radius: var(--radius-full);
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent);
border-radius: var(--radius-full);
transition: width var(--transition-base);
}
.progress-fill[data-tone="amber"] { background: var(--status-blocked); }
.progress-fill[data-tone="red"] { background: var(--status-failed); }
.progress-fill[data-tone="green"] { background: var(--success); }
.tag-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.tag-chip {
display: inline-flex;
padding: 1px 8px;
background: var(--accent-subtle);
color: var(--accent);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.server-pill {
display: inline-flex;
align-items: center;
padding: 2px 10px;
background: rgba(34, 197, 94, 0.14);
border: 1px solid rgba(34, 197, 94, 0.30);
color: var(--success);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-family: var(--font-mono);
text-decoration: none;
}
.server-pill:hover { background: rgba(34, 197, 94, 0.22); }
/* ──────────────────────────────────────────
Card actions
────────────────────────────────────────── */
.project-card-foot {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: auto;
}
.btn-sm {
padding: var(--space-1) var(--space-3);
font-size: var(--text-xs);
font-weight: 500;
border-radius: var(--radius-md);
border: 1px solid transparent;
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
font-family: inherit;
line-height: 1.4;
}
.btn-sm.primary {
background: var(--accent);
color: #ffffff;
border-color: var(--accent);
}
.btn-sm.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-sm.secondary {
background: var(--bg-surface);
color: var(--text-secondary);
border-color: var(--border);
}
.btn-sm.secondary:hover { background: var(--bg-surface-hover); color: var(--text-primary); }
.btn-sm.ghost {
background: transparent;
color: var(--text-muted);
border-color: var(--border);
}
.btn-sm.ghost:hover { color: var(--text-primary); background: var(--bg-surface); }
.btn-sm.danger {
background: rgba(239, 68, 68, 0.14);
color: var(--status-failed);
border-color: rgba(239, 68, 68, 0.45);
}
.btn-sm.danger:hover { background: rgba(239, 68, 68, 0.25); }
.btn-sm:disabled { opacity: 0.55; cursor: not-allowed; }
a.btn-sm { text-decoration: none; display: inline-flex; align-items: center; }
/* ──────────────────────────────────────────
Empty + loading states
────────────────────────────────────────── */
.ws-empty {
text-align: center;
padding: var(--space-10) var(--space-5);
color: var(--text-muted);
background: var(--bg-surface);
border: 1px dashed var(--border);
border-radius: var(--radius-lg);
}
.ws-empty-title {
font-size: var(--text-base);
color: var(--text-primary);
margin-bottom: var(--space-2);
font-weight: 600;
}
.ws-empty p { margin-bottom: var(--space-4); }
.ws-empty code {
font-family: var(--font-mono);
background: var(--bg-elevated);
padding: 1px 6px;
border-radius: var(--radius-sm);
font-size: var(--text-xs);
}
.skel-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-4);
min-height: 200px;
}
.skel-line {
height: 12px;
background: linear-gradient(90deg,
var(--bg-surface-active) 0%,
var(--bg-surface-hover) 50%,
var(--bg-surface-active) 100%);
background-size: 200% 100%;
animation: shimmer 1.4s ease-in-out infinite;
border-radius: var(--radius-sm);
margin-bottom: var(--space-2);
}
.skel-line.w-60 { width: 60%; }
.skel-line.w-40 { width: 40%; }
.skel-line.w-80 { width: 80%; }
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* ──────────────────────────────────────────
Modal — base
────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
display: none;
align-items: center;
justify-content: center;
z-index: 200;
padding: var(--space-4);
}
.modal-overlay[data-open="true"] {
display: flex;
}
.modal {
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
width: 100%;
display: flex;
flex-direction: column;
max-height: 90vh;
overflow: hidden;
}
.modal.size-sm { max-width: 480px; }
.modal.size-lg { max-width: 760px; }
@media (max-width: 768px) {
.modal.size-lg { max-width: 720px; }
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-4);
border-bottom: 1px solid var(--border);
gap: var(--space-3);
}
.modal-title {
font-size: var(--text-base);
font-weight: 600;
color: var(--text-primary);
}
.modal-close {
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
border-radius: var(--radius-md);
cursor: pointer;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: color var(--transition-fast), background var(--transition-fast);
}
.modal-close:hover { color: var(--text-primary); background: var(--bg-surface-hover); }
.modal-close svg { width: 16px; height: 16px; }
.modal-body {
flex: 1;
overflow-y: auto;
padding: var(--space-4);
}
.modal-footer {
border-top: 1px solid var(--border);
padding: var(--space-3) var(--space-4);
display: flex;
justify-content: flex-end;
gap: var(--space-2);
align-items: center;
flex-wrap: wrap;
}
@media (max-width: 640px) {
.modal-overlay { padding: 0; align-items: stretch; }
.modal { max-width: 100vw !important; max-height: 100vh; height: 100vh; border-radius: 0; }
.modal-header { position: sticky; top: 0; background: var(--bg-base); }
.modal-footer { position: sticky; bottom: 0; background: var(--bg-base); padding-bottom: max(var(--space-3), env(safe-area-inset-bottom)); }
}
/* ──────────────────────────────────────────
Form fields (modal)
────────────────────────────────────────── */
.form-field {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.form-label {
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-secondary);
}
.form-input,
.form-textarea {
width: 100%;
padding: var(--space-2) var(--space-3);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: inherit;
font-size: var(--text-sm);
outline: none;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.form-input:focus,
.form-textarea:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--accent-subtle);
}
.form-textarea {
min-height: 96px;
resize: vertical;
line-height: 1.5;
}
.form-input.is-invalid,
.form-textarea.is-invalid {
border-color: var(--error);
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.20);
}
.form-error {
font-size: var(--text-xs);
color: var(--error);
min-height: 1em;
}
.form-checkbox {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
color: var(--text-secondary);
cursor: pointer;
user-select: none;
}
.form-checkbox input[type="checkbox"] {
accent-color: var(--accent);
width: 16px;
height: 16px;
}
/* ──────────────────────────────────────────
Planning chat modal
────────────────────────────────────────── */
.phase-stepper {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
font-size: var(--text-xs);
background: var(--bg-elevated);
flex-wrap: wrap;
}
.phase-step {
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--text-muted);
white-space: nowrap;
}
.step-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--bg-surface-active);
color: var(--text-muted);
font-weight: 600;
}
.phase-step.active .step-num {
background: var(--accent);
color: #ffffff;
}
.phase-step.complete .step-num {
background: var(--success);
color: #ffffff;
}
.phase-step.active { color: var(--text-primary); font-weight: 600; }
.phase-step.complete { color: var(--text-secondary); }
.phase-sep {
color: var(--text-muted);
opacity: 0.6;
}
@media (max-width: 640px) {
.phase-stepper .phase-step:not(.active) span:not(.step-num):not(.phase-sep) {
display: none;
}
}
.chat-stream {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
overflow-y: auto;
min-height: 320px;
max-height: 50vh;
}
.chat-msg {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 85%;
}
.chat-msg.user { align-self: flex-end; }
.chat-msg.assistant { align-self: flex-start; }
.chat-bubble {
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
line-height: 1.5;
word-wrap: break-word;
}
.chat-msg.user .chat-bubble {
background: var(--accent);
color: #ffffff;
border-bottom-right-radius: var(--radius-sm);
}
.chat-msg.assistant .chat-bubble {
background: var(--bg-surface);
color: var(--text-primary);
border-bottom-left-radius: var(--radius-sm);
}
.chat-bubble p { margin: 0 0 var(--space-2) 0; }
.chat-bubble p:last-child { margin-bottom: 0; }
.chat-bubble code {
font-family: var(--font-mono);
background: rgba(0,0,0,0.25);
padding: 1px 6px;
border-radius: var(--radius-sm);
font-size: 0.95em;
}
.chat-bubble pre {
font-family: var(--font-mono);
background: rgba(0,0,0,0.30);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
overflow-x: auto;
font-size: var(--text-xs);
}
.chat-bubble ul, .chat-bubble ol { padding-left: var(--space-5); margin: 0 0 var(--space-2) 0; }
.chat-bubble strong { font-weight: 600; }
.chat-meta {
font-size: 11px;
color: var(--text-muted);
padding: 0 var(--space-2);
}
.typing {
display: inline-flex;
align-items: center;
gap: 4px;
}
.typing-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
animation: typing 1.2s infinite ease-in-out;
}
.typing-dot:nth-child(2) { animation-delay: 0.15s; }
.typing-dot:nth-child(3) { animation-delay: 0.30s; }
@keyframes typing {
0%, 80%, 100% { opacity: 0.3; transform: translateY(0); }
40% { opacity: 1; transform: translateY(-3px); }
}
.elapsed-counter {
font-size: var(--text-xs);
color: var(--text-muted);
padding: 0 var(--space-3);
}
.elapsed-counter[data-tone="warn"] { color: var(--warning); }
.long-banner {
margin: 0 var(--space-4) var(--space-3) var(--space-4);
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
background: rgba(167, 139, 250, 0.18);
color: var(--status-planning);
border-radius: var(--radius-md);
display: none;
}
.long-banner[data-active="true"] { display: block; }
.composer {
border-top: 1px solid var(--border);
padding: var(--space-3) var(--space-4);
display: flex;
gap: var(--space-2);
align-items: flex-end;
}
.composer textarea {
flex: 1;
min-height: 48px;
max-height: 200px;
padding: var(--space-2) var(--space-3);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: inherit;
font-size: var(--text-sm);
outline: none;
resize: none;
line-height: 1.4;
}
.composer textarea:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--accent-subtle);
}
@media (max-width: 640px) {
.composer { padding-bottom: max(var(--space-3), env(safe-area-inset-bottom)); }
}
/* ──────────────────────────────────────────
Toasts
────────────────────────────────────────── */
.toast-stack {
position: fixed;
left: 50%;
bottom: var(--space-5);
transform: translateX(-50%);
display: flex;
flex-direction: column-reverse;
gap: var(--space-2);
z-index: 300;
pointer-events: none;
}
.toast {
pointer-events: auto;
min-width: 280px;
max-width: 480px;
padding: var(--space-3) var(--space-4);
background: var(--bg-base);
border: 1px solid var(--border);
border-left: 3px solid var(--text-muted);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
font-size: var(--text-sm);
color: var(--text-primary);
opacity: 0;
transform: translateY(8px);
transition: opacity var(--transition-base), transform var(--transition-base);
}
.toast[data-visible="true"] { opacity: 1; transform: translateY(0); }
.toast[data-type="success"] { border-left-color: var(--success); }
.toast[data-type="info"] { border-left-color: var(--accent); }
.toast[data-type="warning"] { border-left-color: var(--warning); }
.toast[data-type="busy"] { border-left-color: var(--status-planning); }
.toast[data-type="error"] { border-left-color: var(--error); }
.toast-action {
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
border-radius: var(--radius-sm);
padding: 4px 10px;
font-size: var(--text-xs);
cursor: pointer;
font-family: inherit;
}
.toast-action:hover { color: var(--text-primary); background: var(--bg-surface-hover); }
/* ──────────────────────────────────────────
Visually-hidden helper (SR-only)
────────────────────────────────────────── */
.sr-only {
position: absolute !important;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden; clip: rect(0,0,0,0);
white-space: nowrap; border: 0;
}
</style>
</head>
<body>
<!--NAV-->
<main class="main">
<header class="ws-header" aria-label="Workspace toolbar">
<div class="ws-title-block">
<div class="ws-title">Workspace</div>
<span class="ws-count" id="projectCount" aria-live="polite">0 proiecte</span>
</div>
<div class="ws-toolbar">
<button type="button"
class="live-indicator"
id="liveIndicator"
data-mode="connecting"
aria-label="Conexiune: în conectare"
title="Click pentru reconectare">
<span class="live-dot" aria-hidden="true"></span>
<span class="live-indicator-text" id="liveLabel">Conectare</span>
</button>
<button type="button" class="btn-sm primary" id="proposeBtn">Propose</button>
<button type="button" class="btn-sm secondary" id="refreshBtn">Refresh</button>
</div>
</header>
<section id="projectsSection" aria-live="polite">
<div class="projects-grid" id="projectsGrid">
<div class="skel-card"><div class="skel-line w-60"></div><div class="skel-line w-80"></div><div class="skel-line w-40"></div></div>
<div class="skel-card"><div class="skel-line w-40"></div><div class="skel-line w-80"></div><div class="skel-line w-60"></div></div>
<div class="skel-card"><div class="skel-line w-80"></div><div class="skel-line w-60"></div><div class="skel-line w-40"></div></div>
</div>
</section>
</main>
<!-- ──────────────────────────────────────────
Propose modal (Variant B)
────────────────────────────────────────── -->
<div class="modal-overlay"
id="proposeModal"
data-open="false"
role="dialog"
aria-modal="true"
aria-labelledby="proposeTitle">
<div class="modal size-sm">
<div class="modal-header">
<span class="modal-title" id="proposeTitle">Propune un proiect</span>
<button type="button" class="modal-close" aria-label="Închide" data-close="propose">
<i data-lucide="x" aria-hidden="true"></i>
</button>
</div>
<form id="proposeForm" class="modal-body" novalidate>
<div class="form-field">
<label class="form-label" for="proposeSlug">Slug</label>
<input type="text"
id="proposeSlug"
class="form-input"
name="slug"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
placeholder="ex: roa2web, btgo-playwright">
<div class="form-error" id="proposeSlugErr" role="alert" aria-live="polite"></div>
</div>
<div class="form-field">
<label class="form-label" for="proposeDesc">Descriere</label>
<textarea id="proposeDesc"
class="form-textarea"
name="description"
placeholder="Ce face proiectul? (minim 10 caractere)"></textarea>
<div class="form-error" id="proposeDescErr" role="alert" aria-live="polite"></div>
</div>
<label class="form-checkbox">
<input type="checkbox" id="proposeWithPlanning" checked>
<span>Planifică cu Echo imediat după propunere</span>
</label>
<details class="form-advanced">
<summary>Avansat — feature pe repo existent</summary>
<p class="form-hint" style="font-size:0.85em;color:var(--text-muted);margin:0.5em 0;">
Setează doar dacă slug-ul nu corespunde unui repo Gitea (e o feature pe alt repo).
</p>
<div class="form-field">
<label class="form-label" for="proposeRepo">Repo Gitea</label>
<input type="text" id="proposeRepo" class="form-input" name="repo"
autocomplete="off" autocapitalize="off" spellcheck="false"
placeholder="ex: roa2web (default = slug)">
</div>
<div class="form-field">
<label class="form-label" for="proposeBranch">Branch nou</label>
<input type="text" id="proposeBranch" class="form-input" name="branch"
autocomplete="off" autocapitalize="off" spellcheck="false"
placeholder="ex: feature/telegram-bonuri">
</div>
<div class="form-field">
<label class="form-label" for="proposeBaseBranch">Base branch</label>
<input type="text" id="proposeBaseBranch" class="form-input" name="base_branch"
autocomplete="off" autocapitalize="off" spellcheck="false"
placeholder="default: main">
</div>
</details>
</form>
<div class="modal-footer">
<button type="button" class="btn-sm secondary" data-close="propose">Anulează</button>
<button type="button" class="btn-sm primary" id="proposeSubmit">Propune</button>
</div>
</div>
</div>
<!-- ──────────────────────────────────────────
Planning chat modal (Variant A)
────────────────────────────────────────── -->
<div class="modal-overlay"
id="planModal"
data-open="false"
role="dialog"
aria-modal="true"
aria-labelledby="planTitle">
<div class="modal size-lg">
<div class="modal-header">
<span class="modal-title" id="planTitle">Planning · <span id="planSlug"></span></span>
<button type="button" class="modal-close" aria-label="Închide" data-close="plan">
<i data-lucide="x" aria-hidden="true"></i>
</button>
</div>
<nav class="phase-stepper" aria-label="Faze planning" id="phaseStepper">
<span class="phase-step" data-phase="office-hours">
<span class="step-num">1</span>
<span>Office hours</span>
</span>
<span class="phase-sep"></span>
<span class="phase-step" data-phase="ceo-review">
<span class="step-num">2</span>
<span>CEO review</span>
</span>
<span class="phase-sep"></span>
<span class="phase-step" data-phase="eng-review">
<span class="step-num">3</span>
<span>Eng review</span>
</span>
</nav>
<div class="chat-stream" id="chatStream" aria-live="polite"></div>
<div class="long-banner" id="longBanner" role="status">
Se procesează — răspuns lung, așteptăm…
</div>
<form class="composer" id="composerForm" autocomplete="off">
<textarea id="composerInput"
rows="2"
aria-label="Mesaj pentru Echo"
placeholder="Scrie un mesaj... (Enter trimite, Shift+Enter linie nouă)"></textarea>
<button type="submit" class="btn-sm primary" id="composerSend">Trimite</button>
</form>
<div class="modal-footer">
<span class="elapsed-counter" id="elapsedCounter" aria-live="polite"></span>
<span style="flex:1"></span>
<button type="button" class="btn-sm ghost" data-close="plan">Mai gândim</button>
<button type="button" class="btn-sm danger" id="planCancelBtn">Anulează</button>
<button type="button" class="btn-sm primary" id="planFinalizeBtn" style="background: var(--success); border-color: var(--success);">
Dau drumul tonight
</button>
</div>
</div>
</div>
<!-- ──────────────────────────────────────────
Toast stack
────────────────────────────────────────── -->
<div class="toast-stack" id="toastStack" aria-live="polite"></div>
<script>
(function () {
'use strict';
// ──────────────────────────────────────────
// Theme handling (parity with other pages)
// ──────────────────────────────────────────
window.toggleTheme = function () {
const cur = document.documentElement.getAttribute('data-theme');
const next = cur === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', next);
try { localStorage.setItem('theme', next); } catch (e) {}
};
try {
const saved = localStorage.getItem('theme');
if (saved) document.documentElement.setAttribute('data-theme', saved);
} catch (e) {}
// ──────────────────────────────────────────
// Constants & state
// ──────────────────────────────────────────
const POLL_MS = 5000;
const STATUS_ORDER = {
'running-ralph': 0,
'running-manual': 1,
'planning': 2,
'approved': 3,
'pending': 4,
'blocked': 5,
'failed': 6,
'idle': 7,
'complete': 8,
};
const state = {
projects: [],
version: 0,
mode: 'connecting', // 'connecting' | 'live' | 'polling' | 'offline'
eventSource: null,
pollHandle: null,
// planning chat session-state
planning: {
slug: null,
phase: null,
phasesCompleted: [],
messages: [], // [{role, content, ts}]
inflight: false,
elapsedTimer: null,
elapsedSec: 0,
dirty: false,
},
// active modal stack: ['propose', 'plan']
openModals: [],
};
// ──────────────────────────────────────────
// DOM refs
// ──────────────────────────────────────────
const grid = document.getElementById('projectsGrid');
const projectCountEl = document.getElementById('projectCount');
const liveIndicator = document.getElementById('liveIndicator');
const liveLabel = document.getElementById('liveLabel');
const refreshBtn = document.getElementById('refreshBtn');
const proposeBtn = document.getElementById('proposeBtn');
const proposeModal = document.getElementById('proposeModal');
const proposeForm = document.getElementById('proposeForm');
const proposeSubmit = document.getElementById('proposeSubmit');
const proposeSlug = document.getElementById('proposeSlug');
const proposeDesc = document.getElementById('proposeDesc');
const proposeSlugErr = document.getElementById('proposeSlugErr');
const proposeDescErr = document.getElementById('proposeDescErr');
const proposeWithPlanning = document.getElementById('proposeWithPlanning');
const planModal = document.getElementById('planModal');
const planSlugEl = document.getElementById('planSlug');
const phaseStepper = document.getElementById('phaseStepper');
const chatStream = document.getElementById('chatStream');
const composerForm = document.getElementById('composerForm');
const composerInput = document.getElementById('composerInput');
const composerSend = document.getElementById('composerSend');
const elapsedCounter = document.getElementById('elapsedCounter');
const longBanner = document.getElementById('longBanner');
const planCancelBtn = document.getElementById('planCancelBtn');
const planFinalizeBtn = document.getElementById('planFinalizeBtn');
const toastStack = document.getElementById('toastStack');
// ──────────────────────────────────────────
// Toast system
// ──────────────────────────────────────────
function showToast(message, type, opts) {
type = type || 'info';
opts = opts || {};
const el = document.createElement('div');
el.className = 'toast';
el.dataset.type = type;
el.dataset.visible = 'false';
const isAlert = (type === 'warning' || type === 'busy' || type === 'error');
el.setAttribute('role', isAlert ? 'alert' : 'status');
const text = document.createElement('span');
text.textContent = message;
el.appendChild(text);
if (opts.action && opts.actionLabel) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'toast-action';
btn.textContent = opts.actionLabel;
btn.addEventListener('click', () => {
try { opts.action(); } catch (e) {}
dismiss();
});
el.appendChild(btn);
}
toastStack.appendChild(el);
// Force reflow then animate-in
requestAnimationFrame(() => { el.dataset.visible = 'true'; });
const ttl = opts.ttl || (type === 'error' ? 6000 : 3000);
const timer = setTimeout(dismiss, ttl);
function dismiss() {
clearTimeout(timer);
el.dataset.visible = 'false';
setTimeout(() => { if (el.parentNode) el.parentNode.removeChild(el); }, 220);
}
return dismiss;
}
// ──────────────────────────────────────────
// Auth helper — handle 401 globally
// ──────────────────────────────────────────
async function apiFetch(url, opts) {
opts = opts || {};
opts.credentials = 'include';
opts.headers = opts.headers || {};
const res = await fetch(url, opts);
if (res.status === 401) {
showToast('Sesiune expirată — reconectare…', 'error');
setTimeout(() => { window.location.assign('/echo/login'); }, 1500);
throw new Error('unauthorized');
}
return res;
}
// ──────────────────────────────────────────
// Live indicator state
// ──────────────────────────────────────────
function setMode(mode) {
state.mode = mode;
liveIndicator.dataset.mode = mode;
const labels = {
connecting: 'Conectare',
live: 'Live',
polling: 'Polling',
offline: 'Offline',
};
liveLabel.textContent = labels[mode] || mode;
const ariaLabels = {
connecting: 'Conexiune: în conectare',
live: 'Conexiune: Live',
polling: 'Conexiune: Polling',
offline: 'Conexiune: Offline',
};
liveIndicator.setAttribute('aria-label', ariaLabels[mode] || mode);
}
// ──────────────────────────────────────────
// Sort + render
// ──────────────────────────────────────────
function sortProjects(list) {
return list.slice().sort((a, b) => {
const ra = STATUS_ORDER[a.status] ?? 99;
const rb = STATUS_ORDER[b.status] ?? 99;
if (ra !== rb) return ra - rb;
return (a.slug || '').localeCompare(b.slug || '');
});
}
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function fmtAgo(iso) {
if (!iso) return '';
const t = new Date(iso).getTime();
if (isNaN(t)) return '';
const diff = Math.max(0, Date.now() - t);
const sec = Math.floor(diff / 1000);
if (sec < 60) return 'acum ' + sec + 's';
const min = Math.floor(sec / 60);
if (min < 60) return 'acum ' + min + 'm';
const hr = Math.floor(min / 60);
if (hr < 24) return 'acum ' + hr + 'h';
const day = Math.floor(hr / 24);
return 'acum ' + day + 'z';
}
function fmtEtaSec(sec) {
if (sec == null || sec <= 0) return '';
if (sec < 60) return '~' + sec + 's';
const m = Math.round(sec / 60);
if (m < 60) return '~' + m + 'm';
const h = Math.floor(m / 60);
const rem = m % 60;
return '~' + h + 'h' + (rem ? ' ' + rem + 'm' : '');
}
function approvedRelEta() {
// Relative time to tonight's 23:00 local. If past, show tomorrow.
const now = new Date();
const target = new Date(now);
target.setHours(23, 0, 0, 0);
if (target.getTime() <= now.getTime()) {
target.setDate(target.getDate() + 1);
}
const diffMs = target.getTime() - now.getTime();
const totalMin = Math.max(0, Math.floor(diffMs / 60000));
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
if (h <= 0) return 'în ' + m + 'm';
return 'în ' + h + 'h ' + m + 'm';
}
function statusLabel(status) {
const map = {
'running-ralph': 'Ralph rulează',
'running-manual': 'Manual rulează',
'planning': 'Planning',
'approved': 'Aprobat',
'pending': 'Pending',
'blocked': 'Blocat',
'failed': 'Eșuat',
'complete': 'Complet',
'idle': 'Idle',
};
return map[status] || status || 'unknown';
}
function statusAriaLabel(status) {
return 'Status: ' + statusLabel(status);
}
function pillUsesPulseDot(status) {
return status === 'running-ralph'
|| status === 'running-manual'
|| status === 'planning';
}
// Returns array of {label, type:'primary'|'secondary'|'ghost'|'danger'|'success', action, dataset?}
function buttonsForState(p) {
const slug = p.slug;
switch (p.status) {
case 'running-ralph':
return [
{ label: 'Stop Ralph', type: 'danger', action: () => stopRalph(slug) },
{ label: 'Logs', type: 'secondary', action: () => openLogs(slug) },
{ label: 'PRD', type: 'ghost', action: () => openPrd(slug) },
];
case 'running-manual':
return [
{ label: 'Stop', type: 'danger', action: () => stopManual(slug) },
// The link is rendered separately via server pill.
{ label: 'Logs', type: 'ghost', action: () => openLogs(slug) },
];
case 'planning':
return [
{ label: 'Continuă chat', type: 'primary', action: () => openPlanModal(slug) },
{ label: 'Anulează', type: 'ghost', action: () => cancelPlanning(slug) },
];
case 'approved':
return [
{ label: 'Dezaprobă', type: 'secondary', action: () => unapproveProject(slug) },
{ label: 'Re-planifică', type: 'ghost', action: () => replanProject(slug, p.description) },
];
case 'pending':
return [
{ label: 'Aprobă', type: 'primary', action: () => approveProject(slug) },
{ label: 'Planifică cu Echo',type: 'secondary', action: () => startPlanning(slug, p.description) },
{ label: 'Anulează', type: 'ghost', action: () => cancelProject(slug) },
];
case 'blocked':
return [
{ label: 'Logs', type: 'primary', action: () => openLogs(slug) },
{ label: 'Reia', type: 'secondary', action: () => approveProject(slug) },
];
case 'failed':
return [
{ label: 'Logs', type: 'primary', action: () => openLogs(slug) },
{ label: 'Reîncearcă',type: 'secondary', action: () => approveProject(slug) },
{ label: 'Rollback', type: 'ghost', action: () => rollbackProject(slug) },
];
case 'complete':
return [
{ label: 'Vezi plan', type: 'secondary', action: () => openPrd(slug) },
{ label: 'Rulează din nou',type: 'ghost', action: () => approveProject(slug) },
];
case 'idle':
return [
{ label: 'Propune feature', type: 'secondary', action: () => openProposeModal(slug) },
{ label: 'Șterge', type: 'ghost', action: () => confirmDeleteIdle(slug) },
];
default:
return [];
}
}
function progressBarHtml(p) {
const total = p.stories_total || 0;
const done = p.stories_done || 0;
if (total <= 0) return '';
const pct = Math.min(100, Math.round((done / total) * 100));
let tone = '';
if (p.status === 'blocked') tone = 'amber';
else if (p.status === 'failed') tone = 'red';
else if (p.status === 'running-ralph' || p.status === 'running-manual') tone = '';
else if (p.status === 'complete') tone = 'green';
return (
'<div class="progress-row" aria-label="Progres povești">' +
'<div class="progress-meta"><span>' + done + '/' + total + ' stories</span>' +
(p.eta_seconds ? '<span>ETA ' + escapeHtml(fmtEtaSec(p.eta_seconds)) + '</span>' : '') +
'</div>' +
'<div class="progress-bar" role="progressbar" aria-valuenow="' + pct + '" aria-valuemin="0" aria-valuemax="100">' +
'<div class="progress-fill"' + (tone ? ' data-tone="' + tone + '"' : '') + ' style="width:' + pct + '%"></div>' +
'</div>' +
'</div>'
);
}
function bodyExtrasHtml(p) {
switch (p.status) {
case 'running-ralph': {
const tags = (p.tags || []).map(t =>
'<span class="tag-chip">' + escapeHtml(t) + '</span>').join('');
return progressBarHtml(p) + (tags ? '<div class="tag-row">' + tags + '</div>' : '');
}
case 'running-manual': {
let s = '';
if (p.pid) s += '<div class="project-meta">PID ' + escapeHtml(String(p.pid)) + '</div>';
return s;
}
case 'planning':
return p.last_message_preview
? '<div class="project-preview">' + escapeHtml(p.last_message_preview) + '</div>'
: '<div class="project-meta">Planning în curs…</div>';
case 'approved':
return '<div class="project-meta">Queued · ' + escapeHtml(approvedRelEta()) + '</div>';
case 'pending': {
const proposed = p.proposed_at ? 'Propus ' + escapeHtml(fmtAgo(p.proposed_at)) : '';
const desc = p.description
? '<div class="project-description">' + escapeHtml(p.description) + '</div>'
: '';
return (proposed ? '<div class="project-meta">' + proposed + '</div>' : '') + desc;
}
case 'blocked': {
const total = p.stories_total || 0;
const done = p.stories_done || 0;
const blocked = Math.max(0, total - done);
const reason = p.error_reason ? ' · ' + escapeHtml(p.error_reason) : '';
return '<div class="project-meta">' + blocked + ' povești blocate' + reason + '</div>' +
progressBarHtml(p);
}
case 'failed': {
const total = p.stories_total || 0;
const failed = Math.max(0, total - (p.stories_done || 0));
const reason = p.error_reason ? ' · ' + escapeHtml(p.error_reason) : '';
return '<div class="project-error">' + failed + '/' + total + ' povești eșuate' + reason + '</div>' +
progressBarHtml(p);
}
case 'complete': {
const total = p.stories_total || 0;
const done = p.stories_done || 0;
const ago = p.approved_at || p.started_at;
return '<div class="project-meta">' + done + '/' + total + ' done · ' + escapeHtml(fmtAgo(ago)) + '</div>';
}
case 'idle':
return '<div class="project-meta">Nicio lucrare în așteptare</div>';
default:
return '';
}
}
function renderCard(p) {
const usesDot = pillUsesPulseDot(p.status);
const pillInner =
(usesDot ? '<span class="pulse-dot" aria-hidden="true"></span>' : '') +
'<span>' + escapeHtml(statusLabel(p.status)) + '</span>';
const buttons = buttonsForState(p);
const buttonsHtml = buttons.map((b, i) =>
'<button type="button" class="btn-sm ' + b.type + '" data-action-idx="' + i + '">' +
escapeHtml(b.label) + '</button>'
).join('');
// running-manual: render server-pill if we know a port
// (the API doesn't currently expose ports — placeholder via tags)
let serverPill = '';
const article = document.createElement('article');
article.className = 'project-card';
article.dataset.status = p.status;
article.dataset.slug = p.slug;
article.tabIndex = 0;
article.innerHTML =
'<header class="project-card-head">' +
'<div class="project-slug" title="' + escapeHtml(p.slug) + '">' + escapeHtml(p.slug) + '</div>' +
'<span class="status-pill" data-status="' + escapeHtml(p.status) + '" aria-label="' +
escapeHtml(statusAriaLabel(p.status)) + '">' + pillInner + '</span>' +
'</header>' +
'<div class="project-card-body">' + bodyExtrasHtml(p) + serverPill + '</div>' +
(buttonsHtml ? '<footer class="project-card-foot">' + buttonsHtml + '</footer>' : '');
// Wire buttons
const btnEls = article.querySelectorAll('button[data-action-idx]');
btnEls.forEach(btn => {
const idx = parseInt(btn.dataset.actionIdx, 10);
btn.addEventListener('click', (e) => {
e.stopPropagation();
try { buttons[idx].action(); } catch (err) { console.error(err); }
});
});
// Enter on focused card → primary action
article.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.target === article) {
const primary = buttons.find(b => b.type === 'primary' || b.type === 'danger');
if (primary) {
e.preventDefault();
try { primary.action(); } catch (err) { console.error(err); }
}
}
});
return article;
}
function renderProjects(payload) {
state.projects = payload.projects || [];
state.version = typeof payload.version === 'number' ? payload.version : state.version;
projectCountEl.textContent = state.projects.length + ' proiect' + (state.projects.length === 1 ? '' : 'e');
grid.innerHTML = '';
if (state.projects.length === 0) {
const empty = document.createElement('div');
empty.className = 'ws-empty';
empty.innerHTML =
'<div class="ws-empty-title">Niciun proiect în <code>~/workspace/</code></div>' +
'<p>Adaugă unul cu <code>mkdir ~/workspace/&lt;slug&gt;</code> sau folosește butonul de mai jos.</p>' +
'<button type="button" class="btn-sm primary" id="emptyProposeBtn">Propose</button>';
grid.appendChild(empty);
document.getElementById('emptyProposeBtn').addEventListener('click', () => openProposeModal());
return;
}
const sorted = sortProjects(state.projects);
const frag = document.createDocumentFragment();
sorted.forEach(p => frag.appendChild(renderCard(p)));
grid.appendChild(frag);
if (window.lucide) lucide.createIcons();
}
// ──────────────────────────────────────────
// Fetch & SSE
// ──────────────────────────────────────────
async function fetchProjects() {
try {
const res = await apiFetch('/echo/api/projects', { cache: 'no-store' });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
renderProjects(data);
return data;
} catch (err) {
if (err.message === 'unauthorized') return null;
if (state.mode !== 'live') setMode('offline');
console.error('fetchProjects failed:', err);
return null;
}
}
function startPolling() {
if (state.pollHandle) return;
setMode('polling');
state.pollHandle = setInterval(fetchProjects, POLL_MS);
}
function stopPolling() {
if (state.pollHandle) {
clearInterval(state.pollHandle);
state.pollHandle = null;
}
}
function closeSSE() {
if (state.eventSource) {
try { state.eventSource.close(); } catch (e) {}
state.eventSource = null;
}
}
function startSSE() {
closeSSE();
if (typeof EventSource === 'undefined') {
startPolling();
return;
}
try {
state.eventSource = new EventSource('/echo/api/projects/stream', { withCredentials: true });
} catch (e) {
startPolling();
return;
}
state.eventSource.addEventListener('open', () => {
stopPolling();
setMode('live');
});
state.eventSource.addEventListener('message', (ev) => {
stopPolling();
setMode('live');
let data;
try { data = JSON.parse(ev.data); }
catch (e) { return; }
if (data && data.type === 'ping') return;
if (data && Array.isArray(data.projects)) renderProjects(data);
});
state.eventSource.addEventListener('error', () => {
if (!state.eventSource || state.eventSource.readyState === EventSource.CLOSED) {
state.eventSource = null;
startPolling();
} else if (state.mode !== 'live') {
setMode('polling');
fetchProjects();
}
});
}
function reconnect() {
closeSSE();
stopPolling();
setMode('connecting');
fetchProjects().then(() => startSSE());
}
liveIndicator.addEventListener('click', reconnect);
refreshBtn.addEventListener('click', () => { fetchProjects(); });
proposeBtn.addEventListener('click', () => openProposeModal());
// ──────────────────────────────────────────
// Modal helpers (open/close + focus trap)
// ──────────────────────────────────────────
let lastFocusBeforeModal = null;
function trapFocus(modal, e) {
if (e.key !== 'Tab') return;
const focusables = modal.querySelectorAll(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
const list = Array.from(focusables).filter(el => el.offsetParent !== null);
if (!list.length) return;
const first = list[0], last = list[list.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
function openModal(modalEl, opts) {
opts = opts || {};
lastFocusBeforeModal = document.activeElement;
modalEl.dataset.open = 'true';
state.openModals.push(modalEl.id);
// Auto-focus the first focusable
setTimeout(() => {
const focusEl = modalEl.querySelector(opts.focusSelector || 'input, textarea, button:not(.modal-close)');
if (focusEl) try { focusEl.focus(); } catch (e) {}
}, 60);
}
function closeModal(modalEl) {
modalEl.dataset.open = 'false';
state.openModals = state.openModals.filter(id => id !== modalEl.id);
if (lastFocusBeforeModal && lastFocusBeforeModal.focus) {
try { lastFocusBeforeModal.focus(); } catch (e) {}
}
}
// Close-X buttons & overlay click
document.querySelectorAll('[data-close="propose"]').forEach(b => {
b.addEventListener('click', () => requestCloseProposeModal());
});
document.querySelectorAll('[data-close="plan"]').forEach(b => {
b.addEventListener('click', () => requestClosePlanModal());
});
proposeModal.addEventListener('click', (e) => {
if (e.target === proposeModal) requestCloseProposeModal();
});
planModal.addEventListener('click', (e) => {
if (e.target === planModal) requestClosePlanModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && state.openModals.length) {
const top = state.openModals[state.openModals.length - 1];
if (top === 'planModal') requestClosePlanModal();
else if (top === 'proposeModal') requestCloseProposeModal();
}
if (e.key === 'Tab' && state.openModals.length) {
const top = state.openModals[state.openModals.length - 1];
const m = document.getElementById(top);
if (m) trapFocus(m, e);
}
});
// ──────────────────────────────────────────
// Propose modal
// ──────────────────────────────────────────
function clearProposeErrors() {
proposeSlugErr.textContent = '';
proposeDescErr.textContent = '';
proposeSlug.classList.remove('is-invalid');
proposeDesc.classList.remove('is-invalid');
}
function openProposeModal(prefilledSlug) {
clearProposeErrors();
proposeSlug.value = prefilledSlug || '';
proposeDesc.value = '';
proposeWithPlanning.checked = true;
const focusSel = prefilledSlug ? '#proposeDesc' : '#proposeSlug';
openModal(proposeModal, { focusSelector: focusSel });
}
function requestCloseProposeModal() {
// No dirty-confirm for propose modal (low cost to retype)
closeModal(proposeModal);
}
proposeSubmit.addEventListener('click', (e) => {
e.preventDefault();
submitPropose();
});
proposeForm.addEventListener('submit', (e) => {
e.preventDefault();
submitPropose();
});
async function submitPropose() {
clearProposeErrors();
const slug = (proposeSlug.value || '').trim();
const desc = (proposeDesc.value || '').trim();
const repo = (document.getElementById('proposeRepo')?.value || '').trim();
const branch = (document.getElementById('proposeBranch')?.value || '').trim();
const baseBranch = (document.getElementById('proposeBaseBranch')?.value || '').trim();
let invalid = false;
if (!slug || !/^[a-z0-9][a-z0-9_-]{0,49}$/i.test(slug)) {
proposeSlugErr.textContent = 'Slug invalid (folosește litere/cifre/_/-, max 50)';
proposeSlug.classList.add('is-invalid');
invalid = true;
}
if (desc.length < 10) {
proposeDescErr.textContent = 'Descrierea trebuie să aibă minim 10 caractere';
proposeDesc.classList.add('is-invalid');
invalid = true;
}
if (invalid) return;
proposeSubmit.disabled = true;
const restoreLabel = proposeSubmit.textContent;
proposeSubmit.textContent = 'Se trimite…';
try {
const body = { slug, description: desc };
if (repo) body.repo = repo;
if (branch) body.branch = branch;
if (baseBranch) body.base_branch = baseBranch;
const res = await apiFetch('/echo/api/projects/propose', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (res.status === 400) {
const data = await safeJson(res);
const fields = (data && data.fields) || {};
if (fields.slug) { proposeSlugErr.textContent = fields.slug; proposeSlug.classList.add('is-invalid'); }
if (fields.description) { proposeDescErr.textContent = fields.description; proposeDesc.classList.add('is-invalid'); }
if (!fields.slug && !fields.description) {
showToast((data && data.error) || 'Validare eșuată', 'error');
}
return;
}
if (res.status === 409) {
proposeSlugErr.textContent = 'Slug-ul există deja';
proposeSlug.classList.add('is-invalid');
return;
}
if (!res.ok) {
const data = await safeJson(res);
showToast((data && data.error) || ('HTTP ' + res.status), 'error');
return;
}
// Success
closeModal(proposeModal);
await fetchProjects();
if (proposeWithPlanning.checked) {
await openPlanModal(slug, /* startIfMissing */ true, desc);
} else {
showToast('Adăugat ' + slug + ' în pending', 'success');
}
} catch (err) {
if (err.message === 'unauthorized') return;
showToast('Eroare: ' + (err.message || err), 'error');
} finally {
proposeSubmit.disabled = false;
proposeSubmit.textContent = restoreLabel;
}
}
async function safeJson(res) { try { return await res.json(); } catch (e) { return null; } }
// ──────────────────────────────────────────
// Action handlers (state-changing POSTs)
// ──────────────────────────────────────────
async function postWithVersion(url, body, opts) {
opts = opts || {};
const headers = { 'Content-Type': 'application/json' };
const ver = state.version;
if (ver != null) headers['If-Match'] = String(ver);
let res;
try {
res = await apiFetch(url, {
method: 'POST',
headers,
body: body ? JSON.stringify(body) : undefined,
});
} catch (err) {
if (err.message !== 'unauthorized') showToast('Eroare: ' + (err.message || err), 'error');
return null;
}
if (res.status === 409) {
// stale version → refetch + retry once
showToast('Stare schimbată — se reîmprospătează…', 'warning');
await new Promise(r => setTimeout(r, 500));
await fetchProjects();
if (!opts.noRetry) {
return postWithVersion(url, body, { noRetry: true });
}
return null;
}
return res;
}
async function approveProject(slug) {
const res = await postWithVersion('/echo/api/projects/approve', { slug });
if (!res) return;
if (res.ok) {
showToast('Aprobat ' + slug + ' · tonight 23:00', 'success');
await fetchProjects();
} else {
const d = await safeJson(res);
showToast((d && d.error) || ('HTTP ' + res.status), 'error');
}
}
async function unapproveProject(slug) {
const res = await postWithVersion('/echo/api/projects/unapprove', { slug });
if (!res) return;
if (res.ok) {
showToast('Dezaprobat ' + slug, 'info');
await fetchProjects();
} else {
const d = await safeJson(res);
showToast((d && d.error) || ('HTTP ' + res.status), 'error');
}
}
async function cancelProject(slug) {
const res = await postWithVersion('/echo/api/projects/cancel', { slug });
if (!res) return;
if (res.ok) {
showToast('Anulat ' + slug, 'info');
await fetchProjects();
} else {
const d = await safeJson(res);
showToast((d && d.error) || ('HTTP ' + res.status), 'error');
}
}
async function cancelPlanning(slug) {
try {
const res = await apiFetch('/echo/api/projects/' + encodeURIComponent(slug) + '/plan/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (res.ok) {
showToast('Planning anulat', 'info');
await fetchProjects();
} else {
const d = await safeJson(res);
showToast((d && d.error) || ('HTTP ' + res.status), 'error');
}
} catch (err) {
if (err.message !== 'unauthorized') showToast('Eroare: ' + (err.message || err), 'error');
}
}
async function startPlanning(slug, description) {
await openPlanModal(slug, true, description);
}
async function replanProject(slug, description) {
const ok = confirm(
'Anulezi aprobarea curentă pentru `' + slug + '` și restartezi planning-ul?\n\n' +
'Aprobarea (data și final-plan.md) va fi pierdută; vei reintra în Office hours.'
);
if (!ok) return;
await openPlanModal(slug, true, description, /* force */ true);
}
async function rollbackProject(slug) {
if (!confirm('Rollback (git revert HEAD) pe ' + slug + '? Decrementează ultima poveste trecută.')) return;
try {
const res = await apiFetch('/echo/api/ralph/' + encodeURIComponent(slug) + '/rollback', { method: 'POST' });
const d = await safeJson(res);
if (res.ok && d && d.success) {
showToast('Rollback OK', 'success');
await fetchProjects();
} else {
showToast((d && d.message) || 'Rollback eșuat', 'error');
}
} catch (err) {
if (err.message !== 'unauthorized') showToast('Eroare: ' + (err.message || err), 'error');
}
}
async function stopRalph(slug) {
if (!confirm('Oprește Ralph pe ' + slug + '?')) return;
try {
const res = await apiFetch('/echo/api/ralph/' + encodeURIComponent(slug) + '/stop', { method: 'POST' });
const d = await safeJson(res);
if (res.ok && d && d.success) {
showToast('Ralph oprit', 'info');
await fetchProjects();
} else {
showToast((d && d.error) || 'Stop eșuat', 'error');
}
} catch (err) {
if (err.message !== 'unauthorized') showToast('Eroare: ' + (err.message || err), 'error');
}
}
async function stopManual(slug) {
if (!confirm('Oprește procesul manual pe ' + slug + '?')) return;
try {
const res = await apiFetch('/echo/api/workspace/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project: slug, target: 'main' }),
});
const d = await safeJson(res);
if (res.ok && d && d.success) {
showToast('Proces oprit', 'info');
await fetchProjects();
} else {
showToast((d && d.error) || 'Stop eșuat', 'error');
}
} catch (err) {
if (err.message !== 'unauthorized') showToast('Eroare: ' + (err.message || err), 'error');
}
}
function confirmDeleteIdle(slug) {
const typed = prompt('Pentru a șterge ' + slug + ', tastează slug-ul exact:');
if (typed !== slug) {
showToast('Ștergere anulată', 'info');
return;
}
doDeleteProject(slug);
}
async function doDeleteProject(slug) {
try {
const res = await apiFetch('/echo/api/workspace/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project: slug, confirm: slug }),
});
const d = await safeJson(res);
if (res.ok && d && d.success) {
showToast('Șters ' + slug, 'success');
await fetchProjects();
} else {
showToast((d && d.error) || 'Ștergere eșuată', 'error');
}
} catch (err) {
if (err.message !== 'unauthorized') showToast('Eroare: ' + (err.message || err), 'error');
}
}
// Logs / PRD viewers — keep simple inline display via toast for now
async function openLogs(slug) {
try {
const res = await apiFetch('/echo/api/ralph/' + encodeURIComponent(slug) + '/log?lines=200');
if (res.ok) {
const d = await safeJson(res);
showLogsView(slug + ' · log', (d && (d.lines || []).join('\n')) || '(empty)');
} else {
// Fallback: workspace logs endpoint
const r2 = await apiFetch('/echo/api/workspace/logs?project=' + encodeURIComponent(slug) + '&type=ralph&lines=200');
const d2 = await safeJson(r2);
showLogsView(slug + ' · log', (d2 && (d2.lines || []).join('\n')) || '(empty)');
}
} catch (err) {
if (err.message !== 'unauthorized') showToast('Eroare la log: ' + (err.message || err), 'error');
}
}
async function openPrd(slug) {
try {
const res = await apiFetch('/echo/api/ralph/' + encodeURIComponent(slug) + '/prd');
const d = await safeJson(res);
showLogsView(slug + ' · PRD', JSON.stringify(d, null, 2));
} catch (err) {
if (err.message !== 'unauthorized') showToast('Eroare la PRD: ' + (err.message || err), 'error');
}
}
// Reuse plan modal styling for log viewer (lightweight inline)
function showLogsView(title, body) {
// Minimal popover via toast: full viewer would warrant its own modal,
// but for parity with ralph.html we preserve the existing drawer UX
// by opening a temporary modal-like overlay using the planModal slot
// is excessive. Instead, dump to a generated overlay.
const wrap = document.createElement('div');
wrap.className = 'modal-overlay';
wrap.dataset.open = 'true';
wrap.setAttribute('role', 'dialog');
wrap.setAttribute('aria-modal', 'true');
wrap.innerHTML =
'<div class="modal size-lg">' +
'<div class="modal-header">' +
'<span class="modal-title">' + escapeHtml(title) + '</span>' +
'<button type="button" class="modal-close" aria-label="Închide"><i data-lucide="x" aria-hidden="true"></i></button>' +
'</div>' +
'<div class="modal-body" style="padding:0">' +
'<pre style="margin:0; padding:var(--space-4); font-family:var(--font-mono); font-size:var(--text-xs); white-space:pre-wrap; word-break:break-word; color:var(--text-secondary);">' +
escapeHtml(body) + '</pre>' +
'</div>' +
'</div>';
document.body.appendChild(wrap);
const close = () => {
wrap.dataset.open = 'false';
setTimeout(() => { if (wrap.parentNode) wrap.parentNode.removeChild(wrap); }, 220);
};
wrap.querySelector('.modal-close').addEventListener('click', close);
wrap.addEventListener('click', (e) => { if (e.target === wrap) close(); });
if (window.lucide) lucide.createIcons();
}
// ──────────────────────────────────────────
// Planning chat modal
// ──────────────────────────────────────────
const PHASES = ['office-hours', 'ceo-review', 'eng-review'];
function normalizePhase(phase) {
return (phase || '').replace(/^\//, '').replace(/^plan-/, '');
}
function setPhase(phase, completed) {
state.planning.phase = phase;
state.planning.phasesCompleted = completed || [];
const norm = normalizePhase(phase);
const completedNorm = (completed || []).map(normalizePhase);
const steps = phaseStepper.querySelectorAll('.phase-step');
steps.forEach(step => {
const ph = step.dataset.phase;
step.classList.remove('active', 'complete');
if (completedNorm.indexOf(ph) >= 0) step.classList.add('complete');
if (ph === norm) step.classList.add('active');
});
}
function stripPhaseMarkers(text) {
if (!text) return text;
return text.replace(/\s*\nPHASE_STATUS:\s*(ready_to_advance|needs_input)\s*$/, '').trimEnd();
}
function renderMarkdown(text) {
if (!text) return '';
try {
if (window.marked && window.DOMPurify) {
return window.DOMPurify.sanitize(window.marked.parse(text));
}
} catch (e) {}
return escapeHtml(text);
}
function appendMessage(role, content) {
const display = role === 'assistant' ? stripPhaseMarkers(content) : content;
state.planning.messages.push({ role, content: display, ts: Date.now() });
const msg = document.createElement('div');
msg.className = 'chat-msg ' + role;
const bubble = document.createElement('div');
bubble.className = 'chat-bubble';
if (role === 'assistant') {
bubble.innerHTML = renderMarkdown(display);
} else {
bubble.textContent = display;
}
msg.appendChild(bubble);
chatStream.appendChild(msg);
requestAnimationFrame(() => {
chatStream.scrollTop = chatStream.scrollHeight;
});
}
function appendTypingIndicator() {
const msg = document.createElement('div');
msg.className = 'chat-msg assistant';
msg.id = 'typingIndicator';
const bubble = document.createElement('div');
bubble.className = 'chat-bubble';
bubble.innerHTML =
'<span class="typing">' +
'<span class="typing-dot"></span>' +
'<span class="typing-dot"></span>' +
'<span class="typing-dot"></span>' +
'</span>';
msg.appendChild(bubble);
chatStream.appendChild(msg);
chatStream.scrollTop = chatStream.scrollHeight;
}
function removeTypingIndicator() {
const el = document.getElementById('typingIndicator');
if (el && el.parentNode) el.parentNode.removeChild(el);
}
function startElapsedCounter() {
stopElapsedCounter();
state.planning.elapsedSec = 0;
elapsedCounter.dataset.tone = '';
longBanner.dataset.active = 'false';
elapsedCounter.textContent = 'Echo se gândește... 0s';
state.planning.elapsedTimer = setInterval(() => {
state.planning.elapsedSec += 1;
const s = state.planning.elapsedSec;
if (s < 30) {
elapsedCounter.textContent = 'Echo se gândește... ' + s + 's';
elapsedCounter.dataset.tone = '';
} else if (s < 50) {
elapsedCounter.textContent = 'Echo se gândește... ' + s + 's · răspuns lung';
elapsedCounter.dataset.tone = 'warn';
} else {
elapsedCounter.textContent = 'Echo se gândește... ' + s + 's';
elapsedCounter.dataset.tone = 'warn';
longBanner.dataset.active = 'true';
}
}, 1000);
}
function stopElapsedCounter() {
if (state.planning.elapsedTimer) {
clearInterval(state.planning.elapsedTimer);
state.planning.elapsedTimer = null;
}
elapsedCounter.textContent = '';
elapsedCounter.dataset.tone = '';
longBanner.dataset.active = 'false';
}
function resetPlanModal() {
state.planning.slug = null;
state.planning.phase = null;
state.planning.phasesCompleted = [];
state.planning.messages = [];
state.planning.inflight = false;
state.planning.dirty = false;
chatStream.innerHTML = '';
composerInput.value = '';
composerSend.disabled = false;
stopElapsedCounter();
setPhase(null, []);
}
async function openPlanModal(slug, startIfMissing, descriptionHint, force) {
resetPlanModal();
state.planning.slug = slug;
planSlugEl.textContent = slug;
openModal(planModal, { focusSelector: '#composerInput' });
// Re-planning: skip transcript reuse, go straight to fresh start.
if (force) {
appendTypingIndicator();
startElapsedCounter();
try {
const res = await apiFetch('/echo/api/projects/' + encodeURIComponent(slug) + '/plan/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: descriptionHint || '', force: true }),
});
removeTypingIndicator();
stopElapsedCounter();
if (res.ok) {
const data = await safeJson(res);
if (data) {
setPhase(data.phase || '/office-hours', []);
if (data.message) appendMessage('assistant', data.message);
}
await fetchProjects();
} else {
const d = await safeJson(res);
appendMessage('assistant', '_Eroare la re-planning: ' + ((d && d.message) || ('HTTP ' + res.status)) + '_');
}
} catch (err) {
removeTypingIndicator();
stopElapsedCounter();
if (err.message !== 'unauthorized') {
appendMessage('assistant', '_Eroare: ' + (err.message || err) + '_');
}
}
return;
}
// Try to fetch existing transcript first
try {
const tRes = await apiFetch('/echo/api/projects/' + encodeURIComponent(slug) + '/plan/transcript');
if (tRes.ok) {
const data = await safeJson(tRes);
if (data) {
let rendered = false;
if (Array.isArray(data.history) && data.history.length > 0) {
for (const m of data.history) {
if (m && m.text) appendMessage(m.role || 'assistant', m.text);
}
rendered = true;
} else if (data.last_text_excerpt) {
appendMessage('assistant', data.last_text_excerpt);
rendered = true;
}
const phasesCompleted = data.phases_completed || [];
const phase = data.phase || 'office-hours';
setPhase(phase === '__complete__' ? null : phase, phasesCompleted);
// If we already have content, we're resuming
if (rendered) {
requestAnimationFrame(() => {
chatStream.scrollTop = chatStream.scrollHeight;
});
try { composerInput.focus(); } catch (e) {}
return;
}
}
}
} catch (e) {}
// Otherwise: start a new planning session
if (startIfMissing) {
appendTypingIndicator();
startElapsedCounter();
try {
const res = await apiFetch('/echo/api/projects/' + encodeURIComponent(slug) + '/plan/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: descriptionHint || '' }),
});
removeTypingIndicator();
stopElapsedCounter();
if (res.ok) {
const data = await safeJson(res);
if (data) {
setPhase(data.phase || 'office-hours', []);
if (data.message) appendMessage('assistant', data.message);
}
await fetchProjects();
} else {
const d = await safeJson(res);
if (res.status === 409 && d && d.error === 'already_committed') {
appendMessage('assistant',
'_' + (d.message || 'Proiectul e deja committed.') +
' Folosește «Re-planifică» dacă vrei să o iei de la zero._');
} else {
appendMessage('assistant', '_Eroare la pornirea planning: ' + ((d && d.message) || ('HTTP ' + res.status)) + '_');
}
}
} catch (err) {
removeTypingIndicator();
stopElapsedCounter();
if (err.message !== 'unauthorized') {
appendMessage('assistant', '_Eroare: ' + (err.message || err) + '_');
}
}
}
}
function requestClosePlanModal() {
if (state.planning.dirty) {
if (!confirm('Ai mesaje netrimise. Închizi oricum?')) return;
}
closeModal(planModal);
resetPlanModal();
}
composerInput.addEventListener('input', () => {
state.planning.dirty = composerInput.value.length > 0;
});
composerInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!composerSend.disabled) sendPlanningMessage();
}
});
composerForm.addEventListener('submit', (e) => {
e.preventDefault();
sendPlanningMessage();
});
async function sendPlanningMessage() {
const slug = state.planning.slug;
if (!slug) return;
const text = (composerInput.value || '').trim();
if (!text) return;
appendMessage('user', text);
composerInput.value = '';
state.planning.dirty = false;
composerSend.disabled = true;
state.planning.inflight = true;
appendTypingIndicator();
startElapsedCounter();
try {
const res = await apiFetch('/echo/api/projects/' + encodeURIComponent(slug) + '/plan/respond', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text }),
});
removeTypingIndicator();
stopElapsedCounter();
if (res.status === 202) {
longBanner.dataset.active = 'true';
appendMessage('assistant', '_Se procesează — răspuns lung. Încercăm din nou…_');
return;
}
if (!res.ok) {
const d = await safeJson(res);
appendMessage('assistant', '_Eroare: ' + ((d && d.message) || ('HTTP ' + res.status)) + '_');
return;
}
const data = await safeJson(res);
if (data) {
if (data.message) appendMessage('assistant', data.message);
if (data.phase) {
const completed = state.planning.phasesCompleted.slice();
if (data.phase_ready && !completed.includes(state.planning.phase) && state.planning.phase) {
completed.push(state.planning.phase);
}
setPhase(data.phase === '__complete__' ? null : data.phase, completed);
}
if (data.phase_ready) {
await autoAdvancePhase(slug);
}
}
} catch (err) {
removeTypingIndicator();
stopElapsedCounter();
if (err.message !== 'unauthorized') {
appendMessage('assistant', '_Eroare: ' + (err.message || err) + '_');
}
} finally {
composerSend.disabled = false;
state.planning.inflight = false;
try { composerInput.focus(); } catch (e) {}
}
}
async function autoAdvancePhase(slug) {
const prevPhase = state.planning.phase;
appendMessage('assistant', '_Faza completă. Pornesc subprocess pentru următoarea fază..._');
appendTypingIndicator();
startElapsedCounter();
try {
const res = await apiFetch('/echo/api/projects/' + encodeURIComponent(slug) + '/plan/advance', {
method: 'POST',
});
removeTypingIndicator();
stopElapsedCounter();
if (!res.ok) {
const d = await safeJson(res);
appendMessage('assistant', '_Nu am putut avansa: ' + ((d && d.message) || ('HTTP ' + res.status)) + '_');
return;
}
const data = await safeJson(res);
if (!data) return;
const completed = state.planning.phasesCompleted.slice();
if (prevPhase && !completed.includes(prevPhase)) completed.push(prevPhase);
if (data.completed) {
setPhase(null, completed);
if (data.message) appendMessage('assistant', data.message);
} else {
if (data.message) appendMessage('assistant', data.message);
setPhase(data.phase, completed);
}
} catch (err) {
removeTypingIndicator();
stopElapsedCounter();
if (err.message !== 'unauthorized') {
appendMessage('assistant', '_Eroare la avans fază: ' + (err.message || err) + '_');
}
}
}
planCancelBtn.addEventListener('click', async () => {
const slug = state.planning.slug;
if (!slug) { closeModal(planModal); return; }
if (!confirm('Anulezi planning pentru ' + slug + '?')) return;
await cancelPlanning(slug);
closeModal(planModal);
resetPlanModal();
});
planFinalizeBtn.addEventListener('click', async () => {
const slug = state.planning.slug;
if (!slug) return;
try {
const res = await apiFetch('/echo/api/projects/' + encodeURIComponent(slug) + '/plan/finalize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (res.ok) {
showToast('Aprobat ' + slug + ' · tonight 23:00', 'success');
closeModal(planModal);
resetPlanModal();
await fetchProjects();
} else {
const d = await safeJson(res);
showToast((d && d.error) || ('HTTP ' + res.status), 'error');
}
} catch (err) {
if (err.message !== 'unauthorized') showToast('Eroare: ' + (err.message || err), 'error');
}
});
// ──────────────────────────────────────────
// Auto-open planning if any project is currently planning
// ──────────────────────────────────────────
async function autoOpenPlanning() {
const planningProj = state.projects.find(p => p.status === 'planning');
if (planningProj) {
await openPlanModal(planningProj.slug, false);
}
}
// ──────────────────────────────────────────
// Bootstrap
// ──────────────────────────────────────────
(async function init() {
await fetchProjects();
startSSE();
await autoOpenPlanning();
if (window.lucide) lucide.createIcons();
})();
// Expose a couple of helpers for debugging
window.__echoWorkspace = { state, fetchProjects, reconnect, showToast };
})();
</script>
</body>
</html>