feat(docs): save the whole chat session as a doc
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:
+68
-15
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user