phase: 115_doc_draft_discard
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:
+48
-14
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -90,6 +90,20 @@
|
||||
|
||||
<div class="doc-edit-actions">
|
||||
<button type="submit" id="push-doc-btn">Push to docs branch</button>
|
||||
|
||||
<!-- Phase 115 (task 02): the Discard control — this page's
|
||||
one destructive action. Without it an orphaned draft is
|
||||
un-deletable forever (the screen's only other action
|
||||
is the push). confirm() →
|
||||
DELETE /api/doc-drafts/<token> (admin-gated like the
|
||||
whole router; the uuid4 token is the screen's
|
||||
credential) → 204 → back to the chat (the draft has no
|
||||
other home — no drafts list exists). doc-edit.js owns
|
||||
the handler; a non-204 outcome lands the #push-error
|
||||
banner and does NOT navigate. -->
|
||||
<button type="button" id="discard-draft" class="discard-draft"
|
||||
title="Delete this draft permanently — this cannot be undone">Discard draft</button>
|
||||
|
||||
<a class="doc-edit-back" href="/">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>
|
||||
<span>Back to chat</span>
|
||||
|
||||
Reference in New Issue
Block a user