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",
+69
View File
@@ -45,6 +45,24 @@
* an edit, not a re-type) and the button re-enables;
* • network failure → the fixed one-line copy, same recovery.
*
* Discard (phase 115, task 02 — the page's one destructive action,
* on #discard-draft): a native ``confirm()`` FIRST (destructive +
* irreversible — no undo exists; the shell's alertdialog pattern is
* page-local to the SPA's Sources view, not a shared asset), then
* ``DELETE /api/doc-drafts/<token>`` (the same uuid4 token the GET/
* PUT ran on — the screen's credential; the router is admin-gated
* regardless):
* • 204 → ``location.assign("/")`` — back to the chat (the draft
* has no other home: no drafts list exists);
* • non-204 (a 404 race — the row vanished under us) → the
* #push-error inline banner with the server's detail (422
* shape-aware), the stale success line cleared (one claim at a
* time), NO navigation, no crash;
* • network failure → the fixed one-line copy, same recovery.
* The button runs the §7.4 in-flight lifecycle (disable +
* "Discarding…" while the DELETE is out; restored in the finally —
* never stale).
*
* The shared header module loads through this script's own relative
* import ("./header.js") — a hoisted import evaluated before this body
* runs (single-evaluation design: no direct <script> tag; esbuild
@@ -252,6 +270,57 @@ function wirePush() {
wirePush();
/* ---------- discard (DELETE /api/doc-drafts/<token> — phase 115) ----------
* The page's one destructive action: confirm → DELETE the row (the
* same uuid4 token the GET/PUT ran on) → 204 → back to the chat (the
* draft has no other home — no drafts list exists). Every failure
* lands the inline error banner (the server's detail — 422
* shape-aware) and does NOT navigate: never stale, no crash. */
const DISCARD_LABEL = "Discard draft";
function wireDiscard() {
const discardBtn = document.querySelector("#discard-draft");
if (!discardBtn) return;
discardBtn.addEventListener("click", async () => {
if (!draftToken) {
showError("No draft specified.");
return;
}
// Destructive + irreversible — no undo exists. The native
// confirm() is the approved dialog for this one action on the slim
// flow page (phase 115 task 02 ASSUMPTION — the shell's
// alertdialog is page-local to the Sources view, not a shared
// asset).
if (!confirm("Discard this draft? This cannot be undone.")) return;
discardBtn.disabled = true; // §7.4: one discard per click
discardBtn.textContent = "Discarding…";
try {
const r = await fetch(`/api/doc-drafts/${draftToken}`, {
method: "DELETE",
});
if (r.status === 204) {
// The row is gone — the chat page is the draft's only other
// home.
location.assign("/");
return;
}
// Non-204 (a 404 race — the row is gone — or any other failure):
// the server line in the inline banner, the stale success line
// cleared (one claim at a time), NO navigation, no crash.
setStatus("");
showError(await apiDetail(r, `Could not discard the draft (${r.status}).`));
} catch {
setStatus("");
showError("Could not reach the server — is the app running?");
} finally {
discardBtn.disabled = false; // never stale — success OR failure
discardBtn.textContent = DISCARD_LABEL;
}
});
}
wireDiscard();
/* ---------- boot ----------
* The admin gate FIRST (the sources-gate pattern — one cached
* whoami): anonymous visitors get the gate and NO /api/doc-drafts
+33 -1
View File
@@ -4430,7 +4430,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
}
/* Actions row: the primary Push button (brand, dark ink on brand
5.2:1 — never white on brand) + the back link; wraps at narrow
5.2:1 — never white on brand) + the Discard control (phase 115 —
the secondary destructive action) + the back link; wraps at narrow
widths. */
.doc-edit-actions {
display: flex;
@@ -4455,6 +4456,37 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
#push-doc-btn:hover:not(:disabled) { background: var(--brand-hover); }
#push-doc-btn:disabled { opacity: 0.6; cursor: wait; }
/* Phase 115 (task 02): the Discard control — the SECONDARY destructive
action, visually subordinate to the brand primary (the ghost family
of .steering-delete / .tuning-delete): ink-soft on transparent
(5.1:1 on --surface — more on the darker --bg canvas) with the
--line border; hover joins the err family (err-ink on err-bg 9.3:1,
the err-line border — the state stays text + color, never color
alone); the 44px touch floor, the global 3px :focus-visible ring,
and the :disabled state is the "Discarding…" in-flight affordance. */
.discard-draft {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.85rem;
white-space: nowrap;
padding-inline: 1rem;
cursor: pointer;
}
.discard-draft:hover:not(:disabled) {
background: var(--err-bg);
color: var(--err-ink);
border-color: var(--err-line);
}
.discard-draft:disabled { opacity: 0.6; cursor: wait; }
/* The success status line (role=status): the ok family (ok-ink on
ok-bg 10.6:1) when a push outcome has landed — min-height holds the
line's space so the layout never jumps when the text lands. Empty