feat(chat): save and view chat history — admin-only saved_chats, History page, open-a-chat return
This commit is contained in:
+167
-1
@@ -124,6 +124,37 @@
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
* All DOM ids match frontend/index.html.
|
||||
*/
|
||||
|
||||
@@ -146,6 +177,7 @@ 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)
|
||||
|
||||
/* Phase 39: the display name resolves from one place — window.BOR_BRAND
|
||||
* (the classic assets/brand.js sets it at parse time; its /api/config
|
||||
@@ -981,6 +1013,128 @@ function restoreConversation() {
|
||||
markLastRetryable(); // phase 49: the restored last brain bubble is retryable
|
||||
}
|
||||
|
||||
/* ---------- save & load saved chats (phase 50, owner-locked 2026-08-29) ----------
|
||||
*
|
||||
* `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).
|
||||
*/
|
||||
let currentChatId = null; // string | null — the linked saved_chats row id
|
||||
|
||||
/* A uuid — for the ?chat=<id> param. The API's path param is uuid.UUID,
|
||||
* so anything else would 422; the client gate keeps the no-fetch rule
|
||||
* (invalid/absent param → no request, plain local restore). */
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/* Boot load (?chat=<id>, phase 50): when the URL carries a VALID uuid
|
||||
* AND whoami says admin, GET the row and render it through the SAME
|
||||
* renderStoredMessage loop as the local restore (pixel-identical), then
|
||||
* link the conversation to the id and mirror it to localStorage (a plain
|
||||
* refresh returns to it the phase-14 way). Returns true on success. Every
|
||||
* other outcome — invalid or absent param, anonymous (no fetch: the gate
|
||||
* would 403), 404 (deleted), network failure, or an unusable payload —
|
||||
* returns false and the caller falls through to the normal local restore;
|
||||
* the 404/network failures also raise the error banner. The ?chat= param
|
||||
* is a one-shot boot instruction: on success the URL is normalized back
|
||||
* to / (replaceState), so a later refresh or a "New chat" + refresh
|
||||
* restores the LOCAL session (the mirror above) instead of re-opening
|
||||
* the saved row. */
|
||||
async function restoreSavedChatFromUrl() {
|
||||
const chatId = new URLSearchParams(window.location.search).get("chat");
|
||||
if (!chatId || !UUID_RE.test(chatId) || !isAdmin) return false;
|
||||
const unavailable = () => {
|
||||
showErrorBanner("That saved chat isn't available — it may have been deleted.");
|
||||
return false;
|
||||
};
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`/api/chats/${chatId}`);
|
||||
} catch {
|
||||
return unavailable(); // network failure → banner + local restore
|
||||
}
|
||||
if (!res.ok) return unavailable(); // 404 (deleted) / 403 (signed out) / 5xx
|
||||
let data = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
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
|
||||
// the restore (nothing HTML-shaped, ever).
|
||||
const messages = (Array.isArray(data?.messages) ? data.messages : []).filter(
|
||||
(m) =>
|
||||
m &&
|
||||
(m.who === "user" || m.who === "brain") &&
|
||||
typeof m.text === "string" &&
|
||||
m.text.length > 0
|
||||
);
|
||||
if (!messages.length) return unavailable();
|
||||
conversation = messages; // REPLACES the local conversation (owner-locked)
|
||||
for (const m of conversation) renderStoredMessage(m);
|
||||
markLastRetryable(); // parity with the local restore: Retry on the last brain bubble
|
||||
currentChatId = chatId; // linked: a subsequent Save updates THIS row
|
||||
saveConversation(); // mirror to localStorage — a plain refresh returns here
|
||||
// The ?chat= param is a one-shot boot instruction: normalize the URL
|
||||
// back to / so a later refresh / "New chat" + refresh restores the
|
||||
// LOCAL session (the mirror above) instead of re-opening this row.
|
||||
history.replaceState(null, "", "/");
|
||||
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;
|
||||
const body = JSON.stringify({ messages: conversation });
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
try {
|
||||
let res;
|
||||
if (currentChatId) {
|
||||
res = await fetch(`/api/chats/${currentChatId}`, { method: "PUT", headers, body });
|
||||
if (res.status === 404) {
|
||||
// Stale link: the row is gone (deleted from History) — unlink and
|
||||
// retry as a create, so the save never silently dies.
|
||||
currentChatId = null;
|
||||
res = await fetch("/api/chats", { method: "POST", headers, body });
|
||||
}
|
||||
} else {
|
||||
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."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (res.status === 201) {
|
||||
const created = await res.json();
|
||||
currentChatId = String(created.id); // fresh Save: link to the new row
|
||||
}
|
||||
sendStatus.textContent = "Conversation saved.";
|
||||
} catch {
|
||||
showErrorBanner("Couldn't save the conversation — is the app reachable?");
|
||||
} finally {
|
||||
saveBtn.disabled = false; // released on EVERY outcome — never stale
|
||||
}
|
||||
}
|
||||
|
||||
/* Brain message save point (on `done`): raw accumulated text + metadata.
|
||||
Phase 17: meta.thinking and phase 37: meta.tools are optional —
|
||||
`undefined` drops the key from the JSON, so turns without them persist
|
||||
@@ -1031,6 +1185,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
|
||||
clearStoredConversation();
|
||||
removeTyping();
|
||||
messagesEl.querySelectorAll(".msg").forEach((el) => el.remove());
|
||||
@@ -1389,6 +1544,13 @@ 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);
|
||||
|
||||
/* Navigate-away save point (phase 20, owner choice 2026-08-24 A1):
|
||||
* leaving the chat mid-turn would otherwise drop the in-flight
|
||||
* answer — the brain message persists only on `done`, and
|
||||
@@ -1422,7 +1584,11 @@ window.addEventListener("pagehide", () => {
|
||||
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)
|
||||
restoreConversation();
|
||||
if (saveBtn) saveBtn.hidden = !isAdmin; // phase 50: absent-not-hidden (phase 16)
|
||||
// Phase 50: /?chat=<id> (valid uuid + admin) boots into the saved
|
||||
// conversation; every other outcome falls through to the local restore.
|
||||
const openedSaved = await restoreSavedChatFromUrl();
|
||||
if (!openedSaved) restoreConversation();
|
||||
loadSuggestions();
|
||||
loadHealth();
|
||||
})();
|
||||
|
||||
@@ -123,6 +123,12 @@ export async function initSharedHeader() {
|
||||
// contract as the Sources link.
|
||||
const navTuning = document.querySelector("#nav-tuning");
|
||||
if (navTuning) navTuning.hidden = !admin;
|
||||
// Phase 50 (owner permission 2026-08-29, `TODO.md` L5): the History
|
||||
// nav link (the phase-34 one-bar contract — it ships on every page)
|
||||
// — admin-only, the same ship-hidden / reveal-for-admin contract as
|
||||
// the Tuning link above.
|
||||
const navHistory = document.querySelector("#nav-history");
|
||||
if (navHistory) navHistory.hidden = !admin;
|
||||
// Phase 34: the steering panel (phase 15) is module-owned. The
|
||||
// navbar #steering-toggle was removed at owner request (2026-08-28)
|
||||
// — the panel ships hidden and is only kept fresh. Admin: refresh
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/* Brain of Reese — History page (saved chats, phase 50 task 04).
|
||||
*
|
||||
* TODO.md L5 (owner 2026-08-29): "Need a way to save and view chat
|
||||
* history in a new page, then return to that history with a click."
|
||||
*
|
||||
* Wires the admin-only `GET /api/chats` + `DELETE /api/chats/<id>`
|
||||
* endpoints (phase 50 task 02) into the page's full-width table:
|
||||
*
|
||||
* • Title — an `<a href="/?chat=<id>">`: Open IS the title link
|
||||
* ("return to that history with a click") — the chat page boots
|
||||
* into the saved conversation through ?chat= (task 03);
|
||||
* • Messages — the row's message_count;
|
||||
* • Updated — locale date+time, the full ISO in the title attribute;
|
||||
* • Actions — Delete ONLY (phase 51 adds the share column), inline
|
||||
* TWO-STEP confirm (owner-locked 2026-08-29: no native confirm
|
||||
* dialog anywhere in this file) — the first click swaps the button for
|
||||
* "Delete? [Yes] [No]" (focus moves to Yes, so the confirm is
|
||||
* keyboard-reachable), Yes fires the DELETE and removes the row,
|
||||
* No (or a failed request) keeps it.
|
||||
*
|
||||
* Every cell is built with the DOM APIs (textContent) — the title is
|
||||
* user-derived (the auto-title is the first question), so it NEVER
|
||||
* touches innerHTML (XSS-safe by construction, the sources.js house
|
||||
* rule).
|
||||
*
|
||||
* The whoami gate (phase 19 shared-header module, cached promise):
|
||||
* • anonymous → the #history-gate is shown, the table is hidden,
|
||||
* and NO /api/chats request is made at all (the router 403s
|
||||
* anonymous — the story E2E pins the request log);
|
||||
* • admin → the gate hides and `loadChats()` renders the rows; a
|
||||
* 0-row fetch reveals the empty-state row.
|
||||
*
|
||||
* Phase 19/34: the page joins the shared header — initSharedHeader()
|
||||
* runs first (whoami + nav reveal + the steering panel), and the gate
|
||||
* below reuses the SAME cached /api/whoami promise (one request per
|
||||
* page).
|
||||
*/
|
||||
|
||||
import { fetchIsAdmin, initSharedHeader } from "./header.js";
|
||||
|
||||
const tableWrap = document.querySelector("#history-table-wrap");
|
||||
const tbody = document.querySelector("#history-tbody");
|
||||
const emptyRow = document.querySelector("#history-empty-row");
|
||||
const gateEl = document.querySelector("#history-gate");
|
||||
const statusEl = document.querySelector("#history-status");
|
||||
|
||||
/* Action feedback — the role="status" live region above the table
|
||||
(the "never stale" contract: every row action lands a line here,
|
||||
success or failure alike). */
|
||||
function announce(message) {
|
||||
if (statusEl) statusEl.textContent = message;
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
/* One row. The Title cell carries the Open link (/?chat=<id> — the
|
||||
"return to that history with a click" requirement); the Updated
|
||||
cell renders the locale date+time with the full ISO on hover. */
|
||||
function makeRow(chat) {
|
||||
const tr = document.createElement("tr");
|
||||
|
||||
const titleTd = document.createElement("td");
|
||||
titleTd.className = "history-title-cell";
|
||||
titleTd.title = chat.title; // full title on hover (the column ellipsizes)
|
||||
const link = document.createElement("a");
|
||||
link.className = "history-title-link";
|
||||
link.href = "/?chat=" + chat.id; // Open: the chat page boots into this chat
|
||||
link.textContent = chat.title; // user-derived — textContent only
|
||||
titleTd.appendChild(link);
|
||||
tr.appendChild(titleTd);
|
||||
|
||||
const countTd = document.createElement("td");
|
||||
countTd.className = "history-count-cell";
|
||||
countTd.textContent = String(chat.message_count);
|
||||
tr.appendChild(countTd);
|
||||
|
||||
const updatedTd = document.createElement("td");
|
||||
updatedTd.className = "history-updated-cell";
|
||||
updatedTd.title = chat.updated_at; // full ISO on hover
|
||||
updatedTd.textContent = fmtDate(chat.updated_at);
|
||||
tr.appendChild(updatedTd);
|
||||
|
||||
const actionsTd = document.createElement("td");
|
||||
actionsTd.className = "history-actions-cell";
|
||||
actionsTd.appendChild(makeDeleteControl(chat, tr));
|
||||
tr.appendChild(actionsTd);
|
||||
return tr;
|
||||
}
|
||||
|
||||
/* The inline two-step Delete (owner-locked 2026-08-29 — NO native
|
||||
confirm dialog anywhere on this page). The Delete button is
|
||||
replaced, in place, by the "Delete? [Yes] [No]" pair; focus moves
|
||||
to Yes (keyboard-reachable confirm). Yes → DELETE /api/chats/<id>
|
||||
→ the row is removed + the live region line; No or a failed
|
||||
request keeps the row (+ the error line on failure). */
|
||||
function makeDeleteControl(chat, row) {
|
||||
const cell = document.createElement("span");
|
||||
cell.className = "history-actions";
|
||||
|
||||
const del = document.createElement("button");
|
||||
del.type = "button";
|
||||
del.className = "history-delete";
|
||||
del.setAttribute("aria-label", `Delete saved chat: ${chat.title}`);
|
||||
del.textContent = "Delete";
|
||||
|
||||
function restoreDelete() {
|
||||
cell.replaceChildren(del);
|
||||
del.focus(); // focus returns to the (restored) control
|
||||
}
|
||||
|
||||
del.addEventListener("click", () => {
|
||||
const label = document.createElement("span");
|
||||
label.className = "history-confirm-text";
|
||||
label.textContent = "Delete?";
|
||||
const yes = document.createElement("button");
|
||||
yes.type = "button";
|
||||
yes.className = "history-confirm-yes";
|
||||
yes.textContent = "Yes";
|
||||
const no = document.createElement("button");
|
||||
no.type = "button";
|
||||
no.className = "history-confirm-no";
|
||||
no.textContent = "No";
|
||||
yes.addEventListener("click", () =>
|
||||
confirmDelete(chat, row, yes, restoreDelete));
|
||||
no.addEventListener("click", restoreDelete);
|
||||
cell.replaceChildren(label, yes, no);
|
||||
yes.focus(); // the confirm pair takes over the focus
|
||||
});
|
||||
|
||||
cell.appendChild(del); // the shipped state IS the Delete button
|
||||
return cell;
|
||||
}
|
||||
|
||||
/* The confirmed delete: DELETE /api/chats/<id> → the row is removed
|
||||
(+ the empty-state row reappears when it was the last one) and the
|
||||
live region gets `Deleted "<title>".` A 404 means the row is gone
|
||||
(deleted elsewhere) — drop the stale row and say so. Any other
|
||||
failure or a network error keeps the row, restores the Delete
|
||||
button (retryable), and lands the error line. */
|
||||
async function confirmDelete(chat, row, yesBtn, restoreDelete) {
|
||||
yesBtn.disabled = true; // no double-fire while the request is in flight
|
||||
let r;
|
||||
try {
|
||||
r = await fetch(`/api/chats/${chat.id}`, { method: "DELETE" });
|
||||
} catch {
|
||||
announce(`Couldn't delete "${chat.title}" — is the app reachable?`);
|
||||
restoreDelete();
|
||||
return;
|
||||
}
|
||||
if (r.status === 404) {
|
||||
row.remove();
|
||||
showEmptyIfLast();
|
||||
announce("That chat was already deleted.");
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
announce(`Couldn't delete "${chat.title}" — try again.`);
|
||||
restoreDelete();
|
||||
return;
|
||||
}
|
||||
row.remove();
|
||||
showEmptyIfLast();
|
||||
announce(`Deleted "${chat.title}".`);
|
||||
}
|
||||
|
||||
/* The empty-state row reappears exactly when the last data row was
|
||||
removed (the empty row itself ships in the tbody, hidden). */
|
||||
function showEmptyIfLast() {
|
||||
if (!emptyRow || !tbody) return;
|
||||
emptyRow.hidden = tbody.querySelectorAll("tr").length > 1;
|
||||
}
|
||||
|
||||
/* 0-row fetches, non-2xx, and network failures all land on the
|
||||
empty-state row (the sources.js house fallback — the safe state
|
||||
in every case). */
|
||||
function showEmptyState() {
|
||||
if (!tbody) return;
|
||||
tbody.replaceChildren(emptyRow);
|
||||
if (emptyRow) emptyRow.hidden = false;
|
||||
}
|
||||
|
||||
/* GET /api/chats → render the rows (latest activity first — the
|
||||
server's order). A 0-row fetch shows the empty-state row. */
|
||||
async function loadChats() {
|
||||
if (emptyRow) emptyRow.hidden = true;
|
||||
let r;
|
||||
try {
|
||||
r = await fetch("/api/chats");
|
||||
} catch {
|
||||
showEmptyState();
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
showEmptyState();
|
||||
return;
|
||||
}
|
||||
const { chats } = await r.json();
|
||||
if (!chats.length) {
|
||||
showEmptyState();
|
||||
return;
|
||||
}
|
||||
for (const chat of chats) {
|
||||
tbody.appendChild(makeRow(chat));
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// Phase 19/34: the shared header first (whoami + nav reveal + the
|
||||
// steering panel) — the whoami promise is cached, so the gate below
|
||||
// reuses the SAME single /api/whoami request.
|
||||
await initSharedHeader();
|
||||
if (!(await fetchIsAdmin())) {
|
||||
// Anonymous: the gate in, the table out — and NO /api/chats
|
||||
// request at all: the router 403s anonymous, so the page must
|
||||
// never call it (the story E2E pins the request log).
|
||||
if (tableWrap) tableWrap.hidden = true;
|
||||
if (gateEl) gateEl.hidden = false;
|
||||
return;
|
||||
}
|
||||
if (gateEl) gateEl.hidden = true;
|
||||
loadChats();
|
||||
})();
|
||||
+173
-1
@@ -308,6 +308,36 @@ 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 16: header auth controls (Sign in link / Sign out button) — the
|
||||
same ghost pill as New chat, so the bar keeps one visual language.
|
||||
ink-soft on surface ≈6.9:1; hover pair brand-ink/brand-soft ≈6.9:1.
|
||||
@@ -1746,6 +1776,136 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* ---------- History page (phase 50) ----------
|
||||
/history.html: the admin-only saved-chats list (task 04). The
|
||||
FULL-WIDTH table in the 72rem frame (AGENTS.md rule 5 — no skinny
|
||||
single-column list), the same table language as the Sources page
|
||||
(the sources-table family: --line hairlines, the brand-soft-tinted
|
||||
thead, row hover, the scrollable .table-wrap). Every pair reuses
|
||||
the Phase-08 AA palette: brand-ink on brand-soft 6.9:1, ink-soft
|
||||
>=6.9:1, err 9.1:1. :focus-visible via the global 3px outline
|
||||
rule. No CDN, system fonts. */
|
||||
.history-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
/* Action feedback line (role=status): the sync-result shape —
|
||||
ink-soft on surface (>=4.5:1), small mono; the min-height holds
|
||||
the layout so a delete's line never reflows the table. */
|
||||
.history-status {
|
||||
display: block;
|
||||
min-height: 1.2em;
|
||||
color: var(--ink-soft);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8rem;
|
||||
padding-block: 0.25rem;
|
||||
}
|
||||
/* The table is FULL-WIDTH (AGENTS.md rule 5): width 100% inside the
|
||||
standard .container; the .table-wrap card + its horizontal scroll
|
||||
cover narrow widths (the phase-07 responsive contract). */
|
||||
.history-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 640px;
|
||||
font-size: 0.93rem;
|
||||
}
|
||||
.history-table th, .history-table td {
|
||||
text-align: left;
|
||||
padding: 0.7rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.history-table th {
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand-ink);
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.history-table tbody tr:hover { background: var(--bg); }
|
||||
.history-table tbody tr:last-child td { border-bottom: 0; }
|
||||
/* Title cell: the Open link — the accent link (brand-ink on surface
|
||||
6.9:1); the column ellipsizes, the full title sits in the title
|
||||
attribute (on the cell AND the link). */
|
||||
.history-title-cell {
|
||||
max-width: 34rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.history-title-link {
|
||||
color: var(--brand-ink);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.history-title-link:hover { text-decoration: underline; }
|
||||
.history-title-link:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
|
||||
/* Messages: the mono numeric readout (ink-soft >=6.9:1). */
|
||||
.history-count-cell { font-family: var(--mono); color: var(--ink-soft); }
|
||||
/* Updated: locale date+time (ink-soft), the full ISO in the title
|
||||
attribute (history.js). */
|
||||
.history-updated-cell { color: var(--ink-soft); white-space: nowrap; }
|
||||
/* Actions: the Delete ghost button (the tuning row-action language)
|
||||
+ the inline two-step confirm pair (phase 50 task 04). */
|
||||
.history-actions { display: inline-flex; align-items: center; gap: 0.4rem; }
|
||||
.history-delete {
|
||||
min-height: 44px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.history-delete:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
|
||||
.history-delete:disabled { opacity: 0.5; cursor: wait; }
|
||||
.history-confirm-text { color: var(--err-ink); font-size: 0.82rem; font-weight: 700; white-space: nowrap; }
|
||||
/* Yes: the error-rose treatment (err-ink on err-bg 9.1:1, err-line
|
||||
border); No: the ghost (transparent, --line border). */
|
||||
.history-confirm-yes {
|
||||
min-height: 44px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--err-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--err-bg);
|
||||
color: var(--err-ink);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.history-confirm-yes:hover:not(:disabled) { background: rgb(239 68 68 / 0.18); }
|
||||
.history-confirm-yes:disabled { opacity: 0.6; cursor: wait; }
|
||||
.history-confirm-no {
|
||||
min-height: 44px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.history-confirm-no:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
||||
/* Empty-state row: the muted centered message at full table width
|
||||
(the .git-sources-empty language, inline in the table). */
|
||||
.history-empty-row td {
|
||||
padding: 2.25rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--ink-soft);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ---------- Document viewer (phase 10; two-row header since phase 34) ---------- */
|
||||
/* Phase 34 (owner confirmation 2026-08-26): the viewer header is TWO
|
||||
rows in one sticky <header> — row 1 reuses the standard .app-header /
|
||||
@@ -2177,7 +2337,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, .auth-link { padding: 0.45rem 0.5rem; }
|
||||
.new-chat-btn, .save-chat-btn, .auth-link { padding: 0.45rem 0.5rem; }
|
||||
}
|
||||
|
||||
/* ---------- Responsive (mobile-first adjustments) ---------- */
|
||||
@@ -2280,10 +2440,17 @@ 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; }
|
||||
/* But on the chat page there is room — keep the label visible and
|
||||
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; }
|
||||
/* Phase 16: the auth pill goes icon-only like New chat — brand text
|
||||
ellipsizes as the designated squeeze target, no bar overflow. */
|
||||
.auth-link { padding: 0.4rem 0.3rem; }
|
||||
@@ -2392,6 +2559,11 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
#archive-upload-file { min-width: 0; }
|
||||
#git-source-add,
|
||||
#archive-upload-btn { width: 100%; }
|
||||
/* Phase 50: the History table keeps its full width (the .table-wrap
|
||||
horizontal scroll already covers it); the actions cell wraps so
|
||||
the two-step confirm pair fits the phone width. */
|
||||
.history-actions-cell { white-space: normal; }
|
||||
.history-actions { flex-wrap: wrap; }
|
||||
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
|
||||
main { padding-bottom: env(safe-area-inset-bottom, 0); }
|
||||
/* Sync button goes icon-only on mobile; the label hides, aria-label
|
||||
|
||||
Reference in New Issue
Block a user