feat(chat): save by default + share anonymously — auto-saved chats, guest-facing Share, success toast, action row

This commit is contained in:
2026-08-31 05:20:25 -04:00
parent c564e317ed
commit 914097abcf
17 changed files with 1803 additions and 491 deletions
+247 -124
View File
@@ -124,44 +124,54 @@
* while a turn is in flight. No banner, no scroll (phase 42): the fresh
* bubble lands where the old one was.
*
* Save the conversation (phase 50, owner-locked 2026-08-29, TODO.md L5):
* the "Save" pill (#save-chat-btn — admin-only, SHIPS HIDDEN, revealed at
* boot only for admin: absent, not hidden, for anonymous) stores the
* CURRENT conversation in Postgres (saved_chats, migration 0008) through
* the admin-only /api/chats CRUD. Upsert semantics keyed by
* `currentChatId` (module scope, string | null): a Save while unlinked
* POSTs /api/chats (the server auto-titles from the first question,
* 120-char cap) and links the conversation to the created row's id; a
* re-Save while linked PUTs the SAME row — the same conversation never
* spawns a second row; a 404 from that PUT (the row was deleted on the
* History page behind our back) unlinks and retries as a create, so a
* stale link can never leave the conversation unsaved. "New chat"
* unlinks (a fresh conversation is unlinked until saved again). Boot
* Auto-save the conversation (phase 55, owner-locked A2, 2026-08-31 —
* the phase-50 Save pill is RETIRED, TODO.md L4 "Save shouldn't be a
* button"): every conversation upserts itself into Postgres
* (saved_chats, migration 0008) at the persistence save points — no
* button, no explicit action. The headless persistConversation() helper
* carries the phase-50 upsert semantics, keyed by `currentChatId`
* (module scope, string | null): a save while unlinked POSTs /api/chats
* (the server auto-titles from the first question, 120-char cap) and
* links the conversation to the created row's id; a save while linked
* PUTs the SAME row — the same conversation never spawns a second row;
* a 404 from that PUT (the row was deleted on the History page behind
* our back) unlinks and retries as a create, so a stale link can never
* wedge the conversation. The module-level `persisting` flag is the
* double-fire guard: the save points can overlap (pagehide during a
* stream), so a call while an upsert is in flight is a no-op — the
* next save point retries. The A2 quiet contract: a FAILED auto-save
* never blocks the conversation — a one-line #send-status note ("Couldn't
* save automatically — will try on the next message."), NO error banner;
* a SUCCESSFUL auto-save is silent (the History page is the visible
* proof — the toast is reserved for share). The row link survives
* reloads: the bor.chat.v1 record carries `chatId` (null when unlinked;
* a pre-55 record without the field reads as null — never throws), so a
* refresh restores the conversation AND its link. "New chat" unlinks
* (a fresh conversation creates a fresh row on its first message). Boot
* load: /?chat=<id> with a VALID uuid AND admin fetches the row and
* renders its messages through the SAME renderStoredMessage loop as the
* phase-14 local restore (sources / thinking / tools / stopped /
* deflection — pixel-identical), links currentChatId to the id, and
* mirrors the conversation to localStorage (a plain refresh returns to
* it the phase-14 way). The ?chat= param is a ONE-SHOT boot instruction:
* the success path normalizes the URL back to / (history.replaceState),
* so a later refresh — or a "New chat" + refresh — restores the LOCAL
* session (the mirror) instead of re-opening the saved row and evicting
* whatever the owner typed since. Invalid/absent param, anonymous (no
* fetch — the gate would 403), 404, or network failure: the normal local
* restore runs instead (404/network also raise the error banner). Save
* feedback
* is status text only ("Conversation saved." / "Nothing to save yet.")
* — the #send-status live region, never stale (PLAN §7.4); failures get
* the error banner. Phase 14's local persistence is untouched: saving is
* an additional, explicit action.
* it the phase-14 way, link included). The ?chat= param is a ONE-SHOT
* boot instruction: the success path normalizes the URL back to /
* (history.replaceState), so a later refresh — or a "New chat" +
* refresh — restores the LOCAL session (the mirror) instead of
* re-opening the saved row and evicting whatever the owner typed since.
* Invalid/absent param, anonymous (no fetch — the gate would 403), 404,
* or network failure: the normal local restore runs instead (404/network
* also raise the error banner). Phase 14's local persistence is
* untouched: auto-save is an additional, automatic upsert.
*
* Share the conversation (phase 51, owner-locked 2026-08-29, TODO.md
* L6): the "Share" pill (#share-chat-btn — admin-only, SHIPS HIDDEN,
* revealed at boot in the SAME admin-reveal block as Save) turns the
* CURRENT conversation into a public read-only link (/shared/<token>,
* a 128-bit uuid4 on the saved_chats row). The save-then-share
* contract: the SAME empty-conversation no-op guard as Save (live
* region, no request); linked (currentChatId set) → POST
* L6 — visible to EVERY visitor since phase 55 task 03: the pill is
* static, always-visible markup with no reveal step, and the write
* surface is public — task 01): the "Share" pill (#share-chat-btn)
* turns the CURRENT conversation into a public read-only link
* (/shared/<token>, a 128-bit uuid4 on the saved_chats row). The
* save-then-share contract: the same
* empty-conversation no-op guard as the auto-save (live region, no
* request); linked (currentChatId set) → POST
* /api/chats/<id>/share (idempotent — the existing token comes back
* unchanged); unlinked → POST /api/chats with { messages: conversation,
* share: true } and link currentChatId to the created id — one action
@@ -173,9 +183,15 @@
* input-like that selects its full URL on focus, .share-link-fallback)
* and the live region reads "Share link ready — copy it from the
* field." (the owner-locked fallback). Success (clipboard) reads
* "Share link copied." 403/5xx → the actionable error banner (signed-
* in hint, like Save); a network failure → the "is the app reachable?"
* banner.
* "Share link copied." Phase 55 task 04 (owner-locked A4): BOTH
* success paths ALSO raise the visual-only share toast (showToast —
* top-right slide-down, auto-dismiss ~4s, single instance; the node is
* aria-hidden, so the #send-status live region remains the sole a11y
* announcer — no double screen-reader read); a failed share NEVER
* toasts (the error banner is the failure UI). 403/5xx → the
* actionable error banner (neutral "try again" — with a public write
* surface a 403 is no longer a sign-in problem for a guest, phase 55
* task 01); a network failure → the "is the app reachable?" banner.
*
* Stale saved chats (phase 53, TODO.md L4): every sync that changes the
* knowledge base bumps the sources generation; a row saved against an
@@ -192,16 +208,16 @@
* runTurn promise so the handler can await the turn's completion
* (behavior-neutral for the existing Retry click, which ignores it).
* Only when the turn completes WITHOUT the error banner does the handler
* persist the linked row through the SAME upsert as Save — PUT
* persist the linked row through the SAME upsert as the auto-save — PUT
* /api/chats/<id> (the server re-stamps sources_version → stale: false);
* a 404 (row deleted from History meanwhile) unlinks and recreates
* (saveCurrentChat's stale-link rule). A regenerate that errors
* (persistConversation's stale-link rule). A regenerate that errors
* mid-stream leaves the row untouched (stale stays true); a regenerate
* STOPPED mid-stream (phase 48) persists the stopped partial. Success
* hides the banner and announces in the #send-status live region
* (PLAN §7.4 never-stale). The banner also clears on "New chat" and on
* a successful manual re-Save (both make the row/conversation no longer
* the one the banner describes).
* a successful auto-save re-stamp (both make the row/conversation no
* longer the one the banner describes).
*
* All DOM ids match frontend/index.html.
*/
@@ -225,8 +241,9 @@ const sendStatus = document.querySelector("#send-status");
const banner = document.querySelector("#kb-banner");
const bannerText = document.querySelector("#kb-banner-text");
const versionEl = document.querySelector("#app-version");
const saveBtn = document.querySelector("#save-chat-btn"); // phase 50: admin-only Save pill (ships hidden)
const shareBtn = document.querySelector("#share-chat-btn"); // phase 51: admin-only Share pill (ships hidden)
// Phase 55 (A2): the phase-50 #save-chat-btn query is GONE — there is no
// Save control; persistConversation() auto-saves headless at the save points.
const shareBtn = document.querySelector("#share-chat-btn"); // phase 55 task 03: Share pill — static markup, visible to every visitor
const staleBanner = document.querySelector("#stale-banner"); // phase 53: the stale banner (ships hidden)
const staleRegenBtn = document.querySelector("#stale-regenerate"); // phase 53: the banner's Regenerate pill
@@ -499,15 +516,15 @@ function openTuneForm(wrap, toggleBtn) {
placeholder="e.g. be more concise — or: assume I'm on NixOS"></textarea>`;
const actions = document.createElement("div");
actions.className = "tune-form-actions";
const saveBtn = document.createElement("button");
saveBtn.type = "submit";
saveBtn.className = "tune-save";
saveBtn.textContent = "Save";
const tuneSaveBtn = document.createElement("button");
tuneSaveBtn.type = "submit";
tuneSaveBtn.className = "tune-save";
tuneSaveBtn.textContent = "Save";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "tune-cancel";
cancelBtn.textContent = "Cancel";
actions.append(saveBtn, cancelBtn);
actions.append(tuneSaveBtn, cancelBtn);
form.appendChild(actions);
const status = document.createElement("p");
status.className = "tune-error";
@@ -517,7 +534,7 @@ function openTuneForm(wrap, toggleBtn) {
form.addEventListener("submit", async (e) => {
e.preventDefault();
saveBtn.disabled = true;
tuneSaveBtn.disabled = true;
status.hidden = true;
try {
const r = await fetch("/api/steering", {
@@ -537,7 +554,7 @@ function openTuneForm(wrap, toggleBtn) {
} catch { /* non-JSON error body */ }
status.textContent = detail;
status.hidden = false;
saveBtn.disabled = false;
tuneSaveBtn.disabled = false;
return; // form kept on failure — the instruction survives
}
const saved = document.createElement("p");
@@ -550,7 +567,7 @@ function openTuneForm(wrap, toggleBtn) {
} catch {
status.textContent = "Could not save the note — is the app reachable?";
status.hidden = false;
saveBtn.disabled = false;
tuneSaveBtn.disabled = false;
}
});
cancelBtn.addEventListener("click", () => {
@@ -938,11 +955,16 @@ function appendMaybeTry(wrap, suggestions) {
*
* A durable LOCAL session (A10 unchanged: the API stays stateless — the
* server stores nothing about the conversation). The whole conversation
* lives in localStorage under a versioned key; a format bump = clean start:
* lives in localStorage under a versioned key; a format bump = clean start.
* Phase 55 (A2): the shape extends IN PLACE with `chatId` — the
* saved_chats row link, so a reload restores the conversation AND its
* link (the same conversation never spawns a second row). A pre-55
* record without the field reads as null (unlinked) — never throws:
*
* bor.chat.v1 → { v: 1, messages: [{ who: "user"|"brain", text,
* sources?, deflected?, suggestions?,
* thinking?, tools?, stopped? }] }
* bor.chat.v1 → { v: 1, chatId: string | null,
* messages: [{ who: "user"|"brain", text,
* sources?, deflected?, suggestions?,
* thinking?, tools?, stopped? }] }
*
* Only RAW TEXT is stored — restore re-renders it through the escape-first
* markdown renderer, so no HTML is ever persisted. Save points: the user
@@ -952,7 +974,9 @@ function appendMaybeTry(wrap, suggestions) {
* optional `stopped` marker; a pre-token stop persists nothing
* brain-side), and the PARTIAL brain message on navigate-away (`pagehide`,
* phase 20 — an in-flight turn keeps whatever had already streamed;
* thinking-only turns persist nothing brain-side). Every localStorage access
* thinking-only turns persist nothing brain-side). Phase 55 (A2): every
* save point ALSO auto-saves the row — persistConversation() (headless,
* quiet on failure, silent on success). Every localStorage access
* is try/catch'd — private mode or quota exhaustion degrades silently to
* in-memory-only chat. If the serialized state outgrows the budget (~700k
* chars, far under the ~5MB quota) the oldest messages are dropped first.
@@ -963,23 +987,32 @@ export const STORAGE_BUDGET_CHARS = 700_000;
let conversation = []; // in-memory copy of the persisted messages
function loadStoredConversation() {
/* The stored record — { chatId, messages } or null. Phase 55 (A2): the
* row link rides the record so a reload restores it. `chatId` is
* OPTIONAL by contract (old-record safety): a pre-55 record without the
* field reads as null (unlinked) — never throws on the missing field. */
function loadStoredRecord() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
if (!raw) return null;
const data = JSON.parse(raw);
if (!data || data.v !== STORAGE_VERSION || !Array.isArray(data.messages)) return [];
if (!data || data.v !== STORAGE_VERSION || !Array.isArray(data.messages)) return null;
const chatId =
typeof data.chatId === "string" && data.chatId.length ? data.chatId : null;
// Legacy/corrupt shape → clean start; keep only well-formed raw-text
// messages (nothing HTML-shaped can survive this filter).
return data.messages.filter(
(m) =>
m &&
(m.who === "user" || m.who === "brain") &&
typeof m.text === "string" &&
m.text.length > 0
);
return {
chatId,
messages: data.messages.filter(
(m) =>
m &&
(m.who === "user" || m.who === "brain") &&
typeof m.text === "string" &&
m.text.length > 0
),
};
} catch {
return []; // unreadable storage: start clean, never throw
return null; // unreadable storage: start clean, never throw
}
}
@@ -988,7 +1021,7 @@ function trimToBudget(messages) {
for (;;) {
let size = Infinity;
try {
size = JSON.stringify({ v: STORAGE_VERSION, messages: out }).length;
size = JSON.stringify({ v: STORAGE_VERSION, chatId: null, messages: out }).length;
} catch {
break; // even one message cannot serialize — keep it in memory only
}
@@ -1002,7 +1035,14 @@ function saveConversation() {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ v: STORAGE_VERSION, messages: trimToBudget(conversation) })
JSON.stringify({
v: STORAGE_VERSION,
// Phase 55 (A2): the row link is persisted with the record (null
// = unlinked) — a reload restores it, so the next save point
// updates the SAME row instead of spawning a duplicate.
chatId: currentChatId,
messages: trimToBudget(conversation),
})
);
} catch {
/* quota/private mode: chat keeps working with in-memory state only */
@@ -1057,20 +1097,28 @@ function renderStoredMessage(m) {
/* On load: re-render the stored conversation (markdown, source chips,
deflected styling, maybe-try chips). addMessage hides the empty state,
so a restored conversation starts right where it was left. */
so a restored conversation starts right where it was left. Phase 55
(A2): the row link is hydrated from the record here, at boot — before
any save point can run — so the next message updates the SAME row
(a pre-55 record restores unlinked, exactly as phase 14 did). */
function restoreConversation() {
conversation = loadStoredConversation();
const record = loadStoredRecord();
conversation = record ? record.messages : [];
currentChatId = record ? record.chatId : null; // phase 55: the link survives reloads
for (const m of conversation) renderStoredMessage(m);
markLastRetryable(); // phase 49: the restored last brain bubble is retryable
}
/* ---------- save & load saved chats (phase 50, owner-locked 2026-08-29) ----------
/* ---------- saved-chats row link (phase 50; auto-saved since phase 55) ----------
*
* `currentChatId` links the local conversation to a saved_chats row:
* set to the created row's id on a fresh Save, set to the opened id on a
* successful /?chat=<id> boot load, cleared by "New chat" and by the
* 404-PUT fallback (the row vanished — recreate, never lose the save).
* null = unlinked (a plain local session, phase 14).
* set to the created row's id on a fresh auto-save (persistConversation
* — the first save point after "New chat"), set to the opened id on a
* successful /?chat=<id> boot load, hydrated from the bor.chat.v1
* record on the local restore (phase 55 — the link survives reloads),
* and cleared by "New chat" and by the 404-PUT fallback (the row
* vanished — recreate, never lose the save). null = unlinked (a plain
* local session, phase 14).
*/
let currentChatId = null; // string | null — the linked saved_chats row id
@@ -1113,7 +1161,7 @@ async function restoreSavedChatFromUrl() {
return unavailable(); // malformed body — treat as unavailable
}
// The API schema guarantees the record shape; the same defensive filter
// as loadStoredConversation keeps a corrupted stored row from poisoning
// as loadStoredRecord keeps a corrupted stored row from poisoning
// the restore (nothing HTML-shaped, ever).
const messages = (Array.isArray(data?.messages) ? data.messages : []).filter(
(m) =>
@@ -1147,24 +1195,38 @@ async function restoreSavedChatFromUrl() {
return true;
}
/* Save the current conversation — the #save-chat-btn handler (phase 50).
* No-op with a live-region line when there is nothing to save. Upsert:
* linked → PUT /api/chats/<id> (re-Save updates the same row; no title in
* the body, so the row keeps its current one); unlinked → POST /api/chats
* (the server auto-titles) and link to the created id. A 404 from the PUT
* — the row was deleted on the History page — unlinks and retries as a
* create, then announces the outcome: the owner is never left with an
* unsaved conversation because of a stale link. 403/5xx/network → the
* error banner with an actionable line (the conversation is intact
* locally either way). Success is status text only — the #send-status
* live region, never stale (PLAN §7.4); no banner. */
async function saveCurrentChat() {
if (!conversation.length) {
sendStatus.textContent = "Nothing to save yet.";
return;
}
if (saveBtn.disabled) return; // one save at a time (double-click guard)
saveBtn.disabled = true;
/* Auto-save the current conversation (phase 55, owner-locked A2) — the
* headless replacement of the phase-50 #save-chat-btn handler (the Save
* pill is gone: no button to press, no UI to update). Called fire-and-
* forget from the persistence save points (the user message on send,
* every brain-done through rememberBrainTurn, the pagehide partial —
* which rides rememberBrainTurn, so no extra wiring). The phase-50
* upsert semantics, unchanged:
*
* • empty conversation → no-op (nothing to save, nothing to say);
* • linked (currentChatId set) → PUT /api/chats/<id> — the SAME row
* updates (no title in the body, so the row keeps its current one);
* a 404 from the PUT — the row was deleted on the History page —
* unlinks and retries as a create, so a stale link can never wedge
* the conversation;
* • unlinked → POST /api/chats (the server auto-titles) and link to
* the created id (201) — the first save point creates the row.
*
* The `persisting` flag is the double-fire guard: the save points can
* overlap (pagehide during a stream), so a call while an upsert is in
* flight is a no-op — the next save point retries. The A2 quiet contract
* on failure (non-ok HTTP or network): a one-line #send-status note,
* NO error banner, the turn never blocks. Success is SILENT (the History
* page is the visible proof — the toast is reserved for share), apart
* from clearing the phase-53 stale banner (a successful re-save
* re-stamps the row to the current generation — the row is no longer
* stale). */
let persisting = false; // phase 55: one upsert at a time (double-fire guard)
async function persistConversation() {
if (!conversation.length) return; // nothing to save
if (persisting) return; // an upsert is already in flight (double-fire guard)
persisting = true;
const body = JSON.stringify({ messages: conversation });
const headers = { "Content-Type": "application/json" };
try {
@@ -1181,23 +1243,27 @@ async function saveCurrentChat() {
res = await fetch("/api/chats", { method: "POST", headers, body });
}
if (!res.ok) {
showErrorBanner(
"Couldn't save the conversation — check you're still signed in and try again."
);
// A2 quiet contract: a failed auto-save never blocks the
// conversation — one status-line note, no error banner, and the
// next save point retries.
sendStatus.textContent =
"Couldn't save automatically — will try on the next message.";
return;
}
if (res.status === 201) {
const created = await res.json();
currentChatId = String(created.id); // fresh Save: link to the new row
currentChatId = String(created.id); // first save: link to the new row
}
sendStatus.textContent = "Conversation saved.";
// Phase 53: a re-Save re-stamps the row to the current generation
// (task 03) — the row is no longer stale, so the banner is done.
// Silent on success (A2) — but a re-save re-stamps the row to the
// current generation (phase 53, task 03): the row is no longer
// stale, so the banner is done.
if (staleBanner) staleBanner.hidden = true;
} catch {
showErrorBanner("Couldn't save the conversation — is the app reachable?");
// A2 quiet contract: a network failure is the same one-line note.
sendStatus.textContent =
"Couldn't save automatically — will try on the next message.";
} finally {
saveBtn.disabled = false; // released on EVERY outcome — never stale
persisting = false; // released on EVERY outcome
}
}
@@ -1250,6 +1316,41 @@ async function copyShareLinkWithFallback(absoluteUrl) {
}
}
/* Share-success toast (phase 55, task 04 — TODO.md L5): the VISIBLE
* confirmation that a share worked. A4 owner-locked: the node is
* aria-hidden (visual only) — the #send-status live region remains the
* a11y announcer, so there is no double screen-reader read. Top-right,
* slides down, auto-dismisses in ~4s. A SINGLE instance: the node is
* lazy-created ONCE and reused — a new toast replaces a pending one
* (clear the prior dismiss timer, re-run the entry) and toasts never
* stack. The text lands via textContent only (XSS-safe). Shown on
* BOTH share-success paths; NEVER on a failure (the error
* banner is the failure UI). Page-script-local by design — the toast
* is chat-page only for this phase (no cross-page module). */
let toastEl = null; // the single toast node — lazy-created, reused
let toastTimer = 0; // the pending auto-dismiss (replaced by a new toast)
function showToast(message) {
if (!toastEl) {
toastEl = document.createElement("div");
toastEl.className = "toast";
toastEl.setAttribute("aria-hidden", "true"); // A4: visual only — #send-status is the announcer
document.body.appendChild(toastEl);
}
toastEl.textContent = message; // XSS-safe text assignment
// Re-trigger the entry even when a toast is already up (a second
// share while the first is showing): clear the pending dismiss,
// drop the visible state, force a reflow (restarts the CSS
// transition), then show again.
clearTimeout(toastTimer);
toastEl.classList.remove("is-visible");
void toastEl.offsetWidth; // force reflow — the entry transition restarts
toastEl.classList.add("is-visible");
toastTimer = setTimeout(() => {
toastEl.classList.remove("is-visible"); // auto-dismiss ~4s
}, 4000);
}
/* Share the current conversation — the #share-chat-btn handler
* (phase 51, owner-locked 2026-08-29, TODO.md L6). No-op with a live-
* region line when there is nothing to share (the same guard as
@@ -1259,7 +1360,11 @@ async function copyShareLinkWithFallback(absoluteUrl) {
* action saves AND shares (owner-locked). Success copies the absolute
* URL (clipboard → inline-field fallback); the live region reads
* "Share link copied." or "Share link ready — copy it from the
* field." 403/5xx → the actionable banner (signed-out hint); a network
* field." Phase 55 task 04: BOTH success paths additionally raise the
* visual-only toast (showToast — aria-hidden; the #send-status line
* stays the a11y announcer; a failed share NEVER toasts — the error
* banner is the failure UI). 403/5xx → the actionable banner
* (neutral — the write surface is public, phase 55 task 01); a network
* failure → the reachable? banner. The double-click guard releases in
* the finally — never stale (PLAN §7.4). */
async function shareCurrentChat() {
@@ -1276,9 +1381,7 @@ async function shareCurrentChat() {
// token comes back unchanged, a new one is minted.
const res = await fetch(`/api/chats/${currentChatId}/share`, { method: "POST" });
if (!res.ok) {
showErrorBanner(
"Couldn't share the conversation — check you're still signed in and try again."
);
showErrorBanner("Couldn't share the conversation — try again.");
return;
}
shareUrl = (await res.json()).share_url;
@@ -1291,9 +1394,7 @@ async function shareCurrentChat() {
body: JSON.stringify({ messages: conversation, share: true }),
});
if (!res.ok) {
showErrorBanner(
"Couldn't share the conversation — check you're still signed in and try again."
);
showErrorBanner("Couldn't share the conversation — try again.");
return;
}
const created = await res.json();
@@ -1301,9 +1402,19 @@ async function shareCurrentChat() {
shareUrl = created.share_url;
}
const copied = await copyShareLinkWithFallback(absoluteShareUrl(shareUrl));
// The #send-status lines stay the a11y announcer (PLAN §7.4 never-
// stale) — the toast below is visual only (aria-hidden, task 04).
sendStatus.textContent = copied
? "Share link copied."
: "Share link ready — copy it from the field.";
// Task 04 (A4): the VISIBLE confirmation rides the same two
// success paths, each with its own text. A failed share never
// toasts — the error banner is the failure UI.
if (copied) {
showToast("Share link copied.");
} else {
showToast("Share link ready — copy it from the field.");
}
} catch {
showErrorBanner("Couldn't share the conversation — is the app reachable?");
} finally {
@@ -1319,11 +1430,12 @@ async function shareCurrentChat() {
* lastBrainWrap, no preceding user record — make a stale or superseded
* click a no-op that resolves nothing). The handler AWAITs the returned
* turn promise, and only when the turn completed WITHOUT the error
* banner persists the linked row through the SAME upsert as Save:
* banner persists the linked row through the SAME upsert as the
* auto-save:
* PUT /api/chats/<id> (the server re-stamps sources_version → the row
* is fresh again); a 404 (the row was deleted from History meanwhile)
* follows saveCurrentChat's stale-link rule — unlink + recreate, so the
* owner is never left with an unsaved conversation. A regenerate that
* follows persistConversation's stale-link rule — unlink + recreate,
* so the owner is never left with an unsaved conversation. A regenerate that
* errors mid-stream leaves the row untouched (stale stays true —
* Regenerate stays available); a regenerate STOPPED mid-stream (phase
* 48) persists the stopped partial (the owner engaged with the new
@@ -1388,6 +1500,10 @@ async function regenerateStaleChat() {
function rememberBrainTurn(rawText, meta) {
conversation.push({ who: "brain", text: rawText || "…", ...meta });
saveConversation();
// Phase 55 (A2): the auto-save rides the brain save point — the row
// updates with the new brain turn + metadata. The pagehide partial
// reuses this helper, so it rides the same path (no extra wiring).
persistConversation();
}
/* ---------- new chat (phase 14) ----------
@@ -1430,7 +1546,7 @@ function applyAuthState() {
function startNewChat() {
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
conversation = [];
currentChatId = null; // phase 50: a new conversation is unlinked until saved
currentChatId = null; // phase 55: unlinked — a fresh row on its first message
if (staleBanner) staleBanner.hidden = true; // phase 53: the banner described the cleared conversation
clearStoredConversation();
removeTyping();
@@ -1564,6 +1680,10 @@ async function runTurn(text, { reask = false } = {}) {
// sent, so a failed/interrupted turn never loses it.
conversation.push({ who: "user", text });
saveConversation();
// Phase 55 (A2): the auto-save rides the save point — an unlinked
// conversation creates its row here (auto-title, server-side), a
// linked one refreshes. Fire-and-forget: it never blocks the turn.
persistConversation();
}
let wrap = null;
@@ -1797,14 +1917,14 @@ input.addEventListener("keydown", (e) => {
});
composer.addEventListener("submit", handleSend);
/* Phase 50 (owner-locked 2026-08-29, TODO.md L5): the Save pill stores
* the current conversation in Postgres (the upsert semantics live in
* saveCurrentChat). The button ships hidden in index.html; the boot
* IIFE below reveals it for admin (absent-not-hidden for anonymous,
* phase 16). Status-only feedback — the live region, never stale. */
saveBtn?.addEventListener("click", saveCurrentChat);
/* Phase 51 (owner-locked 2026-08-29, TODO.md L6): the Share pill —
* same ship-hidden/reveal contract as Save (the boot IIFE below). */
/* Phase 55 (owner-locked A2, 2026-08-31): the phase-50 Save binding is
* GONE with the pill — there is no Save control; persistConversation()
* auto-saves headless at the save points (fire-and-forget, quiet on
* failure, silent on success).
* Phase 51 (owner-locked 2026-08-29, TODO.md L6; visible to every
* visitor since phase 55 task 03): the Share pill is static,
* always-visible markup (no reveal step) — only the click binding
* lives here. */
shareBtn?.addEventListener("click", shareCurrentChat);
/* Phase 53 (task 05): the stale banner's Regenerate pill. The binding
* is inert unless the banner is revealed — which only happens on the
@@ -1839,15 +1959,18 @@ window.addEventListener("pagehide", () => {
nav link; applyAuthState() then applies the chat-page-only gating.
Phase 34: the steering panel's admin boot refresh (count badge) and
the anonymous removal of the tuning surface both happen inside
initSharedHeader() now. */
initSharedHeader() now. Phase 55 (A2): the local restore hydrates
currentChatId from the record (restoreConversation), so the row link
survives a plain reload — no Save pill to reveal anymore. */
(async () => {
await initSharedHeader(); // header.js: whoami + Sign in/out + steering gate
isAdmin = await fetchIsAdmin(); // the same cached promise — one whoami
applyAuthState(); // chat page: the auth pair (idempotent with header.js)
if (saveBtn) saveBtn.hidden = !isAdmin; // phase 50: absent-not-hidden (phase 16)
if (shareBtn) shareBtn.hidden = !isAdmin; // phase 51: the Share pill joins the same block
// Phase 55 (task 03): no Share-reveal step — the pill is static,
// always-visible markup (visible to every visitor, phase 51 contract).
// Phase 50: /?chat=<id> (valid uuid + admin) boots into the saved
// conversation; every other outcome falls through to the local restore.
// conversation; every other outcome falls through to the local restore
// (which hydrates the row link from the record — phase 55).
const openedSaved = await restoreSavedChatFromUrl();
if (!openedSaved) restoreConversation();
loadSuggestions();
+77 -43
View File
@@ -308,44 +308,18 @@ html::after {
whole control below 640px. */
.new-chat-btn svg { width: 16px; height: 16px; display: none; }
/* Phase 50 (owner-locked 2026-08-29, TODO.md L5): the "Save" pill — the
EXACT visual family of .new-chat-btn (same declarations, so the two
chat-shell actions always read as a pair): solid brand pill, --bg text
on --brand (5.2:1, WCAG AA), borderless, ≥44px target, hover lightens
the brand fill, focus-visible via the global 3px rule. Shipped hidden
in index.html — app.js reveals it for admin only (phase 16
absent-not-hidden); ≤640px overrides below mirror the New chat ones
(label stays visible in .chat-shell, icon hidden). */
.save-chat-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 44px;
padding: 0.5rem 0.9rem;
border-radius: 999px;
border: 0;
background: var(--brand);
color: var(--bg);
font: inherit;
font-weight: 700;
font-size: 0.95rem;
white-space: nowrap;
cursor: pointer;
}
.save-chat-btn:hover { background: #f55a72; color: var(--bg); }
/* The save mark is hidden on desktop (the label carries the pill); it is
the whole control below 640px (mirrored in the ≤640 block below). */
.save-chat-btn svg { width: 16px; height: 16px; display: none; }
/* Phase 55 (owner-locked A2, 2026-08-31): the phase-50 Save-pill rules
are GONE with the pill — every conversation auto-saves (app.js);
the chat-shell actions are New chat + Share only.
/* Phase 51 (owner-locked 2026-08-29, TODO.md L6): the "Share" pill —
the EXACT visual family of .save-chat-btn (same declarations, so the
Phase 51 (owner-locked 2026-08-29, TODO.md L6): the "Share" pill —
the EXACT visual family of .new-chat-btn (same declarations, so the
chat-shell actions always read as a pair): solid brand pill, --bg
text on --brand (5.2:1, WCAG AA), borderless, ≥44px target, hover
lightens the brand fill, focus-visible via the global 3px rule.
Shipped hidden in index.html — app.js reveals it for admin only
(phase 16 absent-not-hidden); ≤640px overrides below mirror the
Save ones (label stays visible in .chat-shell, icon hidden there). */
(phase 16 absent-not-hidden); ≤640px overrides below mirror the New
chat ones (label stays visible in .chat-shell, icon hidden there). */
.share-chat-btn {
display: inline-flex;
align-items: center;
@@ -430,6 +404,24 @@ html::after {
flex: 1;
}
/* Phase 55 (task 05, 2026-08-31, TODO.md L6, owner-locked A5): the
chat-actions row — New chat + Share share ONE .chat-actions element
(a normal .chat-shell column child). As a flex ITEM of the column it
spans the column width, but as a flex ROW with align-items: center
(not the column default stretch) each pill keeps its INTRINSIC
content width — two pills side by side, left-aligned in the column,
on desktop. The ≤640px block flips this to a vertical stack
(flex-direction: column + align-items: stretch — full-width pills,
New chat above Share; the existing ≤640px pill rules apply to the
stacked pills unchanged). The 46rem column contract is untouched
(PLAN §7). */
.chat-actions {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.6rem;
}
/* Phase 52 (owner revision 2026-08-30): `flex-grow` is the other half of
the pin. `position: sticky` only ever pulls a box UP toward the
viewport bottom — it can never push a box DOWN to meet it — so on an
@@ -1304,6 +1296,49 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.stale-regenerate:disabled { opacity: 0.6; cursor: wait; }
.stale-regenerate svg { width: 16px; height: 16px; display: block; }
/* Phase 55 (task 04): the share-success toast (TODO.md L5). Top-right,
just under the sticky header (--header-h + a small offset — the
variable already steps 64px → 58px at ≤640px), z-index 1000 (the
modal overlay contract — above the header's 20 and the skip-link's
100). VISUAL ONLY (A4 owner-locked): the node ships aria-hidden
from app.js — #send-status stays the a11y announcer (no double
screen-reader read). Solid brand fill: --bg text on --brand = 5.2:1
(AA — the .new-chat-btn family); rounded, shadowed, one line of
text that wraps under the small max-width. Hidden by default
(opacity 0 + pointer-events: none — it never intercepts clicks when
idle); .toast.is-visible (toggled by showToast) runs the entry:
slide-down + fade (translateY(-8px) → 0, 200ms). */
.toast {
position: fixed;
top: calc(var(--header-h) + 0.75rem);
right: 1rem;
z-index: 1000;
max-width: min(22rem, calc(100vw - 2rem));
padding: 0.6rem 1rem;
border-radius: var(--radius-sm);
background: var(--brand);
color: var(--bg);
font-size: 0.92rem;
font-weight: 600;
box-shadow: var(--shadow);
opacity: 0;
pointer-events: none;
transform: translateY(-8px);
transition: opacity 200ms ease, transform 200ms ease;
}
.toast.is-visible {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
/* No slide: BOTH states rest at transform: none (the .is-visible
rule would otherwise out-specify the bare .toast); the opacity
fade remains (120ms). */
.toast,
.toast.is-visible { transform: none; }
.toast { transition: opacity 120ms ease; }
}
/* ---------- Login page (phase 16) ---------- */
/* Centered card in the standard frame: one admin, one password. */
.login-shell {
@@ -2586,7 +2621,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
designated clip target, pills squeeze next). */
.nav-link { padding: 0.4rem 0.5rem; font-size: 0.85rem; }
.app-nav { gap: 0.15rem; }
.new-chat-btn, .save-chat-btn, .auth-link { padding: 0.45rem 0.5rem; }
.new-chat-btn, .auth-link { padding: 0.45rem 0.5rem; }
}
/* ---------- Responsive (mobile-first adjustments) ---------- */
@@ -2689,13 +2724,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.new-chat-btn { padding: 0.4rem 0.3rem; }
.new-chat-label { display: none; }
.new-chat-btn svg { display: block; }
/* Phase 50: the Save pill squeezes with New chat (same family, same
rules — the chat-shell overrides below keep both labels visible). */
.save-chat-btn { padding: 0.4rem 0.3rem; }
.save-chat-label { display: none; }
.save-chat-btn svg { display: block; }
/* Phase 51: the Share pill squeezes with Save (same family, same
rules — the chat-shell overrides below keep both labels visible). */
/* Phase 51: the Share pill squeezes with New chat (same family, same
rules — the chat-shell override below keeps the label visible). */
.share-chat-btn { padding: 0.4rem 0.3rem; }
.share-chat-label { display: none; }
.share-chat-btn svg { display: block; }
@@ -2703,10 +2733,14 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
hide the icon (the button lives inside .chat-shell, not the navbar). */
.chat-shell .new-chat-label { display: inline; }
.chat-shell .new-chat-btn svg { display: none; }
.chat-shell .save-chat-label { display: inline; }
.chat-shell .save-chat-btn svg { display: none; }
.chat-shell .share-chat-label { display: inline; }
.chat-shell .share-chat-btn svg { display: none; }
/* Phase 55 (task 05, TODO.md L6, A5): the action row flips to a
vertical stack at the phone breakpoint — full-width pills, New
chat above Share. The pill rules above (padding, the
icon/label handling, the .chat-shell label overrides) apply to
the stacked pills unchanged. */
.chat-actions { flex-direction: column; align-items: stretch; gap: 0.5rem; }
/* Phase 53: the stale banner's row wraps at phone width (message
above, action below) — the pill takes a full-width comfortable
row instead of squeezing into the text. */