phase: 115_doc_draft_discard
Build and Push Containers / build-and-push-app (push) Successful in 2m12s
Build and Push Containers / build-and-push-db (push) Successful in 14s

All green — this was the final verification pass; everything from the four completed tasks was already in the working tree and verified.

**Phase 115 — Doc drafts: Discard + DELETE route + title fix — verification report**

- Verified all 4 task deliverables present: DELETE route (`app/api/doc_drafts.py`), Discard UI (`doc-edit.html` + `doc-edit.js` + `.discard-draft` CSS), title fix (`defaultDocTitle(wrap)` pairing + `saveAsDoc` call site), and all test pins (integration, frontend unit, E2E). No code changes needed.
- **Completion criteria:**
  1. ✅ Orphaned draft discardable from edit screen; row gone — `test_delete_removes_row_and_invalidates_token` (204 → GET 404), unknown-token 404, admin-gate 403 on all routes, E2E `test_discard_draft_from_edit_screen` all pass.
  2. ✅ Title after retry redo = redone answer's own question — E2E `test_save_title_is_the_redo_question_after_retry` passes.
  3. ✅ Push flow byte-identical — `git diff` shows only the new DELETE route + module docstring; all 7 existing push tests green.
  4. ✅ `uv run pytest --cov=app` → **2457 passed**, app coverage **99%** (>90%); `uv run pytest tests/e2e/test_save_doc_session.py -v --no-cov` → **4 passed**; `uv run ruff check .` → clean; `uv run pyright` → 0 errors.
  5. ⏳ Commit + phase-dir move left to the harness (per executor rules, no `git` run; all changes left in the working tree).
- No defects found; no deviations.
- Next pending phase: none in `todo/` other than this one (`115_doc_draft_discard` is the last).
This commit is contained in:
2026-09-15 05:35:05 -04:00
parent 3846f26a58
commit 990c8adf13
29 changed files with 1384 additions and 30 deletions
+48 -14
View File
@@ -596,9 +596,11 @@ function markLastRetryable() {
* keeps it rightmost when the last bubble also carries the Retry
* button.
*
* Click: default title (UNCHANGED by phase 75 — the LAST user
* question, whitespace-collapsed, ≤120 chars, the phase-50 auto-title
* convention) + default in-repo path (docs/<slug>.md) + the
* Click: default title (phase 115 — the QUESTION THE ANSWER
* ANSWERED: the saved bubble's paired user bubble, whitespace-
* collapsed, ≤120 chars, the phase-50 auto-title convention; the
* last-conversation-record rule survives as the no-pair fallback) +
* default in-repo path (docs/<slug>.md) + the
* FULL-SESSION transcript as the body (phase 75 A6) → POST
* /api/doc-drafts {title, path, body} → 201 →
* /doc-edit.html?draft=<token> (the edit screen, task 06, owns the
@@ -609,19 +611,47 @@ const SAVE_AS_DOC_ICON =
const DOC_TITLE_MAX = 120; // the phase-50 auto-title cap (owner-locked)
/* The default doc title: the LAST user question's text,
* whitespace-collapsed, truncated to 120 chars — the phase-50
* auto-title convention (server-side: " ".join(text.split())[:120])
* applied to the last question. Defensive "Note" when the
* conversation has no user record (the UI cannot produce one).
/* The default doc title. Phase 115 (task 03 — the TODO L7 side
* observation): when the saved brain bubble's *wrap* is given, the
* title comes from the user bubble PAIRED with it — the nearest
* preceding .msg.user in the DOM conversation flow (#messages' direct
* children are the bubbles, in order; the walk is over the wrap's
* previousElementSibling chain). The pairing is DOM-structural, not
* index-based: redo-in-place (phase 49) reorders the DOM, and the
* structural pair IS the answer's question by construction — the
* last conversation record, after a retry, can be an unrelated
* trailing question (the junk-title edge case). The text is read
* from the bubble's .bubble (the rendered question — the meta rows
* with their button labels live on brain bubbles, never user ones).
* No wrap given, or no paired user bubble found (first-turn edge /
* DOM mismatch) → the pre-phase-115 fallback: the LAST user record
* in `conversation`. Whitespace-collapsed, truncated to 120 chars —
* the phase-50 auto-title convention (server-side:
* " ".join(text.split())[:120]); defensive "Note" when neither
* source yields a question (the UI cannot produce one).
* " ".join(split()) == replace(/\s+/g, " ").trim() for non-empty
* input; the trim keeps the leading/trailing-whitespace edge identical. */
function defaultDocTitle() {
function defaultDocTitle(wrap) {
let question = "";
for (let i = conversation.length - 1; i >= 0; i -= 1) {
if (conversation[i].who === "user") {
question = conversation[i].text;
break;
if (wrap) {
for (
let el = wrap.previousElementSibling;
el !== null;
el = el.previousElementSibling
) {
if (el.classList.contains("msg") && el.classList.contains("user")) {
const bubble = el.querySelector(".bubble");
question = bubble ? (bubble.textContent ?? "") : "";
break;
}
}
}
if (!question) {
for (let i = conversation.length - 1; i >= 0; i -= 1) {
if (conversation[i].who === "user") {
question = conversation[i].text;
break;
}
}
}
return question.replace(/\s+/g, " ").trim().slice(0, DOC_TITLE_MAX) || "Note";
@@ -721,7 +751,11 @@ async function saveAsDoc(btn) {
if (btn.disabled) return; // one save at a time (double-click guard)
btn.disabled = true;
try {
const title = defaultDocTitle();
// Phase 115 (task 03): pass the button's OWN bubble (the .save-
// as-doc-btn lives in the bubble's .msg-meta row, so closest
// climbs button → meta → body → the .msg.brain wrap) — the title
// pairs the answer with ITS question (the redo-in-place fix).
const title = defaultDocTitle(btn.closest(".msg.brain"));
const path = `docs/${docSlug(title)}.md`;
const res = await fetch("/api/doc-drafts", {
method: "POST",