feat(chat): save by default + share anonymously — auto-saved chats, guest-facing Share, success toast, action row
This commit is contained in:
+247
-124
@@ -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
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user