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>
2325 lines
96 KiB
HTML
2325 lines
96 KiB
HTML
<!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, '&').replace(/</g, '<')
|
||
.replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
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/<slug></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>
|