feat(docs): save chat answers as docs — edit screen, commit + push to the .env docs branch

This commit is contained in:
2026-09-01 03:52:03 -04:00
parent 7b7a834a1a
commit 725af9fac1
32 changed files with 4356 additions and 107 deletions
+148 -1
View File
@@ -495,6 +495,126 @@ function markLastRetryable() {
const prev = lastIdx > 0 ? conversation[lastIdx - 1] : null;
if (!prev || prev.who !== "user") return;
appendRetryButton(lastBrainWrap);
// Phase 59: "Save as doc" stays the meta row's rightmost action —
// when the Retry button lands on the SAME bubble, re-append the save
// button after it (the auto margins split the free space between the
// right-aligned buttons; DOM order decides the right edge).
const saveDocBtn = lastBrainWrap.querySelector(".save-as-doc-btn");
if (saveDocBtn && saveDocBtn.parentElement)
saveDocBtn.parentElement.appendChild(saveDocBtn);
}
/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the bottom-right
* "Save as doc" action of EVERY completed brain bubble (deflected
* included — same scope as Tune; a stopped partial is a note, not an
* 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 .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
* 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>';
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).
* " ".join(split()) == replace(/\s+/g, " ").trim() for non-empty
* input; the trim keeps the leading/trailing-whitespace edge identical. */
function defaultDocTitle() {
let 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";
}
/* The default in-repo path slug (phase 59 locked assumption):
* lowercase, runs of non-alphanumerics → "-", trimmed, ≤60 chars,
* empty → "note". The 60-cut can land mid dash-run — the trailing
* trim again keeps the path from ending in a dangling "-". */
function docSlug(title) {
const slug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60)
.replace(/-+$/g, "");
return slug || "note";
}
/* 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
* a plain meta row; one button per bubble. */
function appendSaveAsDocButton(wrap, markdown) {
if (!isAdmin || !docsRepoConfigured) return; // phase 59: admin + configured
const body = wrap.querySelector(".msg-body");
if (!body) return;
let meta = body.querySelector(".msg-meta");
if (!meta) {
meta = document.createElement("div");
meta.className = "msg-meta";
body.appendChild(meta);
}
if (meta.querySelector(".save-as-doc-btn")) return; // one per bubble
const btn = document.createElement("button");
btn.type = "button";
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));
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) {
if (btn.disabled) return; // one save at a time (double-click guard)
btn.disabled = true;
try {
const title = defaultDocTitle();
const path = `docs/${docSlug(title)}.md`;
const res = await fetch("/api/doc-drafts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, path, body: markdown }),
});
if (!res.ok) {
// Neutral one-line copy (phase-55 convention) — the detail may
// be a guard-rail 422 or a server hiccup; neither is actionable
// here, and the conversation stays unblocked (no navigation).
showErrorBanner("Couldn't save the answer as a doc — try again.");
return;
}
const draft = await res.json();
// 201: the draft's uuid4 token IS the edit screen's credential.
location.assign("/doc-edit.html?draft=" + draft.token);
} catch {
showErrorBanner("Couldn't save the answer as a doc — is the app reachable?");
} finally {
btn.disabled = false; // released on EVERY outcome — never stale
}
}
/* Inline tuning form under the bubble: labeled textarea (maxlength 2000)
@@ -1091,6 +1211,10 @@ function renderStoredMessage(m) {
}
appendSources(wrap, m.sources);
appendTuneButton(wrap); // restored brain answers are tunable too
// Phase 59: the RAW persisted markdown (m.text — HTML is never
// persisted). A stopped partial (m.stopped) is a note, not an answer
// — no button (the live stop path adds none either).
if (!m.stopped) appendSaveAsDocButton(wrap, m.text);
if (m.stopped) appendStoppedNote(wrap); // phase 48: the stop marker restores
lastBrainWrap = wrap; // phase 49: the LAST restored brain bubble wins
}
@@ -1538,6 +1662,16 @@ const signInLink = document.querySelector("#sign-in-link");
const signOutBtn = document.querySelector("#sign-out-btn");
let isAdmin = false;
/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the docs-push gate
* — GET /api/config's ``docs_repo_configured`` (settings.docs_configured
* server-side), surfaced by brand.js as window.BOR_DOCS_REPO_CONFIGURED
* (the way app_name is: a window global, false until the boot fetch
* proves otherwise). Captured ONCE in the boot IIFE after the fetch
* settles, so the "Save as doc" buttons render exactly once: present
* for a configured admin, absent for everyone else — and while
* BOR_DOCS_REPO is empty the feature is inert (D3). */
let docsRepoConfigured = false;
function applyAuthState() {
if (signInLink) signInLink.hidden = isAdmin;
if (signOutBtn) signOutBtn.hidden = !isAdmin;
@@ -1812,11 +1946,15 @@ async function runTurn(text, { reask = false } = {}) {
appendMaybeTry(wrap, ev.suggestions);
}
appendSources(wrap, ev.sources);
appendTuneButton(wrap); // every completed brain bubble is tunable
// Thinking-without-answer (reasoning can exhaust max_tokens): the
// bubble gets the empty-answer fallback — what the user saw is
// what gets persisted.
const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : "");
appendTuneButton(wrap); // every completed brain bubble is tunable
// Phase 59: the RAW persisted markdown (never the rendered
// HTML) — exactly the string rememberBrainTurn stores below,
// so a reload (the restore path) offers the identical draft.
appendSaveAsDocButton(wrap, finalText || acc || "…");
if (!acc && sawThinking) {
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText);
}
@@ -1851,6 +1989,7 @@ async function runTurn(text, { reask = false } = {}) {
const fallback = EMPTY_ANSWER_FALLBACK;
const fwrap = addMessage("brain", fallback);
appendTuneButton(fwrap);
appendSaveAsDocButton(fwrap, fallback); // phase 59: parity with the done path
rememberBrainTurn(fallback, {}); // persist what the user actually saw
lastBrainWrap = fwrap;
markLastRetryable(); // phase 49: the fallback bubble is retryable too
@@ -1965,6 +2104,14 @@ window.addEventListener("pagehide", () => {
(async () => {
await initSharedHeader(); // header.js: whoami + Sign in/out + steering gate
isAdmin = await fetchIsAdmin(); // the same cached promise — one whoami
// Phase 59: /api/config is settled BEFORE any bubble renders —
// brand.js's single boot fetch (window.BOR_CONFIG_PROMISE, never
// rejecting) has set window.BOR_DOCS_REPO_CONFIGURED (false until
// proven), so a restored conversation of a configured admin gets the
// "Save as doc" button exactly once: no flash, no re-render, no
// second fetch (the brand fetch IS the config fetch).
await (window.BOR_CONFIG_PROMISE ?? Promise.resolve());
docsRepoConfigured = window.BOR_DOCS_REPO_CONFIGURED === true;
applyAuthState(); // chat page: the auth pair (idempotent with header.js)
// Phase 55 (task 03): no Share-reveal step — the pill is static,
// always-visible markup (visible to every visitor, phase 51 contract).