feat(docs): save the whole chat session as a doc
Build and Push Containers / build-and-push-app (push) Successful in 1m46s
Build and Push Containers / build-and-push-db (push) Successful in 12s

Phase 75 (TODO.md L4): "Save as doc" now drafts a document from the
ENTIRE chat session — every question and answer up to the click, in
order — instead of only the clicked bubble's answer; the existing
doc-edit screen's free-form body editing is how the user edits out
anything they don't want to keep from previous replies (no new UI
surface).

Task 01 (frontend):
- app.js buildSessionTranscript(): walks the bor.chat.v1 conversation
  record in order — a numbered section per user turn ("## N.
  <question, raw>" + blank line + the raw answer text; more answers
  join under the same heading), sections blank-line separated, all
  trailing whitespace collapsed to one final newline. Only the raw
  persisted text travels (m.who + m.text — no thinking blocks, no
  source chips, no tune metadata); a brain record before the first
  user record is skipped; a heading-only section marks a user turn
  whose answer never landed (A6, owner-confirmed 2026-09-08).
- saveAsDoc(btn): the draft body is buildSessionTranscript(); the
  dead single-bubble markdown parameter is dropped (the button's
  appendSaveAsDocButton signature is unchanged — one button per
  bubble). Title/path/double-click guard/hand-off are unchanged
  (defaultDocTitle: the last question, whitespace-collapsed,
  <=120 chars; docs/<slug>.md).
- Unit: the app.js source pins move to the transcript shape (whole
  session, no thinking, no dead parameter).

Task 02 (E2E):
- tests/e2e/test_save_doc_session.py (bare-repo fixture, the
  phase-59 convention — git as source of truth): three DISTINCT
  on-topic turns in one session (turn 1 carries the phase-17
  "think out loud" trigger so its record has a thinking block the
  transcript must exclude) -> save on the LAST bubble -> the
  prefilled body is ## 1./## 2./## 3. in order, byte-exact against
  the deterministic mock, thinking-free -> edit the whole
  section-2 block out of the body -> push -> git show
  bor-docs:<path> equals the EDITED body byte-for-byte (section 2's
  question and answer provably absent; sections 1 and 3 byte-exact;
  the UI's sha prefix is git rev-parse bor-docs). Second test:
  the button on the FIRST bubble still drafts the whole session
  (A6 — the transcript is the session at click time, title stays
  the last question); canceling leaves the branch tip untouched.
- tests/e2e/test_response_to_docs.py: the phase-59 single-turn body
  expectation moves to the transcript shape ("## 1. <question>" +
  the answer's markdown) — the rest of the suite unchanged.

Also lands the phase-74 file moves (00_phase.md /
03_mock_marker_e2e.md -> complete/) and the phase reports — the
house convention of committing .agents/ with the phase.
This commit is contained in:
2026-09-05 16:59:31 -04:00
parent 055c0b5d85
commit 0e4651c779
16 changed files with 974 additions and 34 deletions
+68 -15
View File
@@ -552,19 +552,24 @@ function markLastRetryable() {
* answer, so m.stopped records never get it — the restore call site
* gates on it). Gate: admin (the whoami gate Tune uses) AND a
* configured docs repo (docsRepoConfigured — /api/config, settled in
* the boot IIFE before any bubble renders). `markdown` is the RAW
* persisted answer text — m.text on the restore path, the
* done/fallback raw text on the live path — NEVER the rendered HTML.
* the boot IIFE before any bubble renders). `markdown` — the RAW
* persisted answer text, m.text on the restore path, the
* done/fallback raw text on the live path, NEVER the rendered HTML —
* stays in the signature (the three call sites are unchanged) but no
* longer travels: phase 75 (TODO L4, A6) drafts the WHOLE conversation
* from the `conversation` record at click time (buildSessionTranscript).
* The .save-as-doc-btn's margin-inline-start: auto pushes it to the
* row's right edge (the TODO's "bottom right"); markLastRetryable
* keeps it rightmost when the last bubble also carries the Retry
* button.
*
* Click: default title (the LAST user question, whitespace-collapsed,
* ≤120 chars — the phase-50 auto-title convention) + default in-repo
* path (docs/<slug>.md) → POST /api/doc-drafts {title, path, body} →
* 201 → /doc-edit.html?draft=<token> (the edit screen, task 06, owns
* the rest). Failure → the neutral one-line banner (phase-55
* 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
* 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
* rest). Failure → the neutral one-line banner (phase-55
* convention), the conversation unblocked, no navigation. */
const SAVE_AS_DOC_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H6a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 16h4"/></svg>';
@@ -603,6 +608,52 @@ function docSlug(title) {
return slug || "note";
}
/* Phase 75 (TODO L4; A6, owner-confirmed 2026-09-08): the draft BODY
* is the WHOLE conversation — every user question and the brain
* answers that followed it, in order, as the RAW persisted text. The
* user edits out unwanted turns in the doc-edit body (A7 — the
* existing free-form body field; no new UI surface). The shape: a
* numbered section per USER turn —
*
* ## 1. <user question, raw text>
*
* <brain answer, raw text>
*
* ## 2. <user question, raw text>
*
* <brain answer, raw text>
*
* — 1-based per user turn (normally one answer per section; more
* answers join under the same heading), sections blank-line
* separated, ALL trailing whitespace collapsed to a single final
* newline. ONLY the raw text travels (m.who + m.text — nothing else
* off the record); a stopped/partial brain turn appears as-is (its
* text is what the user saw — A7); a brain record before the first
* user record (unproducible from the UI) is skipped, and a user turn
* whose brain record never landed is a heading-only section. */
function buildSessionTranscript() {
const sections = []; // { heading: "## N. <q>", answers: [raw text] }
let open = null; // answers of the current user section (null while
// no user record has opened one yet)
for (const m of conversation) {
if (m.who === "user") {
open = [];
sections.push({
heading: `## ${sections.length + 1}. ${m.text}`,
answers: open,
});
} else if (open) {
open.push(m.text);
}
}
const body = sections
.map((s) =>
s.answers.length ? `${s.heading}\n\n${s.answers.join("\n\n")}` : s.heading
)
.join("\n\n");
return body.replace(/\s+$/, "") + "\n";
}
/* The bottom-right "Save as doc" button — the appendTuneButton
* pattern: reuses the .msg-meta row when it exists (role=list → the
* button joins as a listitem so ARIA stays valid), otherwise creates
@@ -623,15 +674,17 @@ function appendSaveAsDocButton(wrap, markdown) {
btn.className = "save-as-doc-btn"; // margin-inline-start: auto → bottom-right
if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem");
btn.innerHTML = SAVE_AS_DOC_ICON + "<span>Save as doc</span>";
btn.addEventListener("click", () => saveAsDoc(btn, markdown));
btn.addEventListener("click", () => saveAsDoc(btn));
meta.appendChild(btn);
}
/* Create the draft from the bubble's RAW markdown and hand off to the
* edit screen. Double-click guard: one save at a time (the button is
* disabled until the outcome — released in the finally, never stale,
* PLAN §7.4). */
async function saveAsDoc(btn, markdown) {
/* Create the draft from the FULL-SESSION transcript (phase 75 A6 —
* every Q/A up to the click, in order — replacing the phase-59
* single-bubble body; the title and path rules are unchanged) and
* hand off to the edit screen. Double-click guard: one save at a time
* (the button is disabled until the outcome — released in the
* finally, never stale, PLAN §7.4). */
async function saveAsDoc(btn) {
if (btn.disabled) return; // one save at a time (double-click guard)
btn.disabled = true;
try {
@@ -640,7 +693,7 @@ async function saveAsDoc(btn, markdown) {
const res = await fetch("/api/doc-drafts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, path, body: markdown }),
body: JSON.stringify({ title, path, body: buildSessionTranscript() }),
});
if (!res.ok) {
// Neutral one-line copy (phase-55 convention) — the detail may