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>
This commit is contained in:
2026-05-05 07:47:10 +00:00
parent d0faeed181
commit 8432fe3150
5 changed files with 197 additions and 22 deletions

View File

@@ -1954,18 +1954,29 @@
// ──────────────────────────────────────────
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 ((completed || []).indexOf(ph) >= 0) step.classList.add('complete');
if (ph === phase) step.classList.add('active');
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 {
@@ -1977,15 +1988,16 @@
}
function appendMessage(role, content) {
state.planning.messages.push({ role, content, ts: Date.now() });
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(content);
bubble.innerHTML = renderMarkdown(display);
} else {
bubble.textContent = content;
bubble.textContent = display;
}
msg.appendChild(bubble);
chatStream.appendChild(msg);
@@ -2075,14 +2087,24 @@
if (tRes.ok) {
const data = await safeJson(tRes);
if (data) {
if (data.last_text_excerpt) {
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 (data.last_text_excerpt) {
if (rendered) {
requestAnimationFrame(() => {
chatStream.scrollTop = chatStream.scrollHeight;
});
try { composerInput.focus(); } catch (e) {}
return;
}
@@ -2189,6 +2211,9 @@
}
setPhase(data.phase === '__complete__' ? null : data.phase, completed);
}
if (data.phase_ready) {
await autoAdvancePhase(slug);
}
}
} catch (err) {
removeTypingIndicator();
@@ -2203,6 +2228,42 @@
}
}
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; }