feat(ui): persist the chat conversation in localStorage — survives refresh and navigation, with a New chat reset

This commit is contained in:
2026-08-22 15:52:53 -04:00
parent 2485b50af0
commit 19df7df99d
5 changed files with 681 additions and 7 deletions
+152 -3
View File
@@ -20,6 +20,14 @@
* the 120s guard (TURN_TIMEOUT_MS) catches hung pre-token
* streams, so the button can never sit zombified.
*
* Conversation persistence (phase 14) makes the chat a durable LOCAL
* session: the message list (raw text + turn metadata) lives in
* localStorage under the versioned key `bor.chat.v1` and is re-rendered on
* load — refresh, tab close, and a trip to Sources never lose it. A10 is
* untouched: the API stays stateless, nothing is stored server-side.
* "New chat" (#new-chat-btn) clears the key + the list back to the empty
* state.
*
* All DOM ids match frontend/index.html.
*/
@@ -94,7 +102,7 @@ const USER_AVATAR =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="8" r="3.6"/><path d="M4.8 20.2c.9-3.9 3.8-6 7.2-6s6.3 2.1 7.2 6"/></svg>';
/* ---------- messages ---------- */
function addMessage(who, html) {
function addMessage(who, html, scrollBehavior = SCROLL) {
if (emptyState) emptyState.hidden = true;
const wrap = document.createElement("div");
wrap.className = `msg ${who}`;
@@ -104,7 +112,7 @@ function addMessage(who, html) {
<div class="bubble">${html}</div>
</div>`;
messagesEl.appendChild(wrap);
wrap.scrollIntoView({ behavior: SCROLL, block: "end" });
wrap.scrollIntoView({ behavior: scrollBehavior, block: "end" });
return wrap;
}
@@ -330,6 +338,133 @@ function appendMaybeTry(wrap, suggestions) {
body.appendChild(group);
}
/* ---------- conversation persistence (phase 14) ----------
*
* 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:
*
* bor.chat.v1 → { v: 1, messages: [{ who: "user"|"brain", text,
* sources?, deflected?, suggestions? }] }
*
* 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
* message on send (a failed turn keeps the question) and the brain message
* on `done` (with sources/deflected/suggestions). 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.
*/
const STORAGE_KEY = "bor.chat.v1";
export const STORAGE_VERSION = 1;
export const STORAGE_BUDGET_CHARS = 700_000;
let conversation = []; // in-memory copy of the persisted messages
function loadStoredConversation() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const data = JSON.parse(raw);
if (!data || data.v !== STORAGE_VERSION || !Array.isArray(data.messages)) return [];
// 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
);
} catch {
return []; // unreadable storage: start clean, never throw
}
}
function trimToBudget(messages) {
let out = messages.slice();
for (;;) {
let size = Infinity;
try {
size = JSON.stringify({ v: STORAGE_VERSION, messages: out }).length;
} catch {
break; // even one message cannot serialize — keep it in memory only
}
if (size <= STORAGE_BUDGET_CHARS || out.length <= 1) return out;
out = out.slice(1); // drop the oldest until it fits
}
return out;
}
function saveConversation() {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ v: STORAGE_VERSION, messages: trimToBudget(conversation) })
);
} catch {
/* quota/private mode: chat keeps working with in-memory state only */
}
}
export function clearStoredConversation() {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* nothing was stored */
}
}
function renderStoredMessage(m) {
if (m.who === "user") {
addMessage("user", renderMarkdown(m.text), "auto");
return;
}
const wrap = addMessage("brain", renderMarkdown(m.text), "auto");
if (m.deflected) {
wrap.classList.add("is-deflected");
appendMaybeTry(wrap, m.suggestions);
}
appendSources(wrap, m.sources);
}
/* 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. */
function restoreConversation() {
conversation = loadStoredConversation();
for (const m of conversation) renderStoredMessage(m);
}
/* Brain message save point (on `done`): raw accumulated text only. An
empty stream keeps the "…" placeholder that was actually rendered. */
function rememberBrainTurn(rawText, meta) {
conversation.push({ who: "brain", text: rawText || "…", ...meta });
saveConversation();
}
/* ---------- new chat (phase 14) ----------
* Clears the stored conversation + the rendered list and returns to the
* empty state (suggestions included). Ignored while a turn is in flight —
* a live stream must not be hijacked. Confirmation reuses the existing
* #send-status live region (aria-live=polite). */
const newChatBtn = document.querySelector("#new-chat-btn");
function startNewChat() {
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
conversation = [];
clearStoredConversation();
removeTyping();
messagesEl.querySelectorAll(".msg").forEach((el) => el.remove());
if (emptyState) emptyState.hidden = false;
clearErrorBanner();
setUiState(UI_STATE.idle);
input.value = "";
autoGrow();
input.focus();
sendStatus.textContent = "New chat started — previous conversation cleared.";
}
if (newChatBtn) newChatBtn.addEventListener("click", startNewChat);
function showErrorBanner(detail) {
banner.hidden = false;
banner.classList.add("is-error");
@@ -352,6 +487,10 @@ async function handleSend(e) {
if (!text || sendBtn.disabled) return;
addMessage("user", renderMarkdown(text));
// Persistence save point 1: the question is stored the moment it is
// sent, so a failed/interrupted turn never loses it.
conversation.push({ who: "user", text });
saveConversation();
input.value = "";
autoGrow();
clearErrorBanner();
@@ -406,12 +545,21 @@ async function handleSend(e) {
appendMaybeTry(wrap, ev.suggestions);
}
appendSources(wrap, ev.sources);
// Persistence save point 2: the answer lands only when the turn is
// complete (raw text + the done metadata).
rememberBrainTurn(acc, {
deflected: !!ev.deflected,
sources: ev.sources,
suggestions: ev.suggestions,
});
} else if (ev.type === "error") {
throw new Error(ev.detail || "Something went wrong on my side.");
}
});
if (!aborted && !wrap) {
addMessage("brain", "Hmm — that came back empty. Ask me again?");
const fallback = "Hmm — that came back empty. Ask me again?";
addMessage("brain", fallback);
rememberBrainTurn(fallback, {}); // persist what the user actually saw
}
} catch (err) {
if (!aborted) {
@@ -441,5 +589,6 @@ input.addEventListener("keydown", (e) => {
});
composer.addEventListener("submit", handleSend);
restoreConversation(); // phase 14: the conversation comes back as left
loadSuggestions();
loadHealth();
+47 -4
View File
@@ -174,11 +174,13 @@ body::after {
rgb(34 211 238 / 0.05) 90%
);
}
/* margin-left:auto on the nav (not justify-content:space-between) so the
phase-14 "New chat" pill clusters with the nav on the right while the
two-child Sources header keeps the exact same look. */
.header-inner {
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.brand {
@@ -198,7 +200,7 @@ body::after {
.brand-mark { width: 22px; height: 22px; flex: 0 0 auto; display: block; }
.brand-text strong { color: var(--brand-ink); font-weight: 700; }
.app-nav { display: flex; gap: 0.25rem; }
.app-nav { display: flex; gap: 0.25rem; margin-left: auto; }
.nav-link {
padding: 0.5rem 0.9rem;
border-radius: 999px;
@@ -213,6 +215,32 @@ body::after {
.nav-link:hover { background: var(--brand-soft); color: var(--brand-ink); }
.nav-link.is-active { background: var(--brand); color: var(--bg); }
/* "New chat" reset (phase 14): ghost pill in the chat header, hover like
a nav link. ink-soft on surface ≈6.9:1; hover pair brand-ink/brand-soft
≈6.9:1 — both WCAG AA. Icon-only below 640px (aria-label keeps the
accessible name); ≥44px touch target at every width. */
.new-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: 1px solid var(--line);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.95rem;
white-space: nowrap;
cursor: pointer;
}
.new-chat-btn:hover { background: var(--brand-soft); color: var(--brand-ink); }
/* The plus mark is hidden on desktop (label carries the pill); it is the
whole control below 640px. */
.new-chat-btn svg { width: 16px; height: 16px; display: none; }
/* ---------- Main frame ---------- */
.app-main {
flex: 1;
@@ -754,8 +782,23 @@ body::after {
@media (max-width: 640px) {
:root { --header-h: 58px; }
.container { padding-inline: 0.9rem; }
.brand-text { font-size: 0.88rem; }
.nav-link { padding: 0.45rem 0.7rem; font-size: 0.9rem; }
/* Phase 14: the New chat pill joins the header — tighten the bar so
brand + nav + pill fit at 360px without horizontal overflow (the
brand text may ellipsize as the designated squeeze target). */
.header-inner { gap: 0.6rem; }
.brand { min-width: 0; }
.brand-text {
font-size: 0.8rem;
letter-spacing: 0.04em;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-link { padding: 0.4rem 0.55rem; font-size: 0.88rem; }
.new-chat-btn { padding: 0.4rem 0.55rem; }
.new-chat-label { display: none; }
.new-chat-btn svg { display: block; }
.msg-body { max-width: 92%; }
.empty-state { padding: 1.75rem 1.1rem; margin-top: 0.25rem; }
.empty-state-title { font-size: 1.25rem; }