Files
echo-core/dashboard/workspace.html
Marius Mutu 8432fe3150 feat(planning): full chat history + auto-advance phases
Three fixes that together restore the planning UX:

- Dashboard reopen showed only a 500-char truncated excerpt of the last
  assistant message. Backend now reads the Claude session JSONL directly
  and returns full per-turn history; frontend iterates and renders all
  bubbles, falling back to last_text_excerpt when the JSONL is missing.
- Phases never advanced because the agent ran /plan-* skills inline as
  tool calls and the marker protocol was loose. Tightened the planning
  prompt (mandatory PHASE_STATUS marker on the last line of every turn,
  ban on inline phase invocation), and the frontend now auto-calls
  /plan/advance when phase_ready=true.
- The phase strip never showed visual state because data-phase values
  ("office-hours") didn't match orchestrator phase names ("/office-hours").
  Added normalizePhase + cleanup of PHASE_STATUS markers from rendered
  bubbles.

Also bumps eco.py session-content truncation from 2k to 20k so /eco
session views aren't cut mid-response either.

Bumps last_text_excerpt fallback in planning_session.py from 500 to
50_000 so even when the JSONL is unavailable, the bubble isn't sliced
mid-word.

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

2325 lines
96 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>
</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: 'Planifică', type: 'ghost', action: () => startPlanning(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: 'Rulează Ralph', type: 'secondary', action: () => approveProject(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() {
clearProposeErrors();
proposeSlug.value = '';
proposeDesc.value = '';
proposeWithPlanning.checked = true;
openModal(proposeModal, { focusSelector: '#proposeSlug' });
}
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();
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 res = await apiFetch('/echo/api/projects/propose', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, description: desc }),
});
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 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) {
resetPlanModal();
state.planning.slug = slug;
planSlugEl.textContent = slug;
openModal(planModal, { focusSelector: '#composerInput' });
// 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);
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>