feat(ui): one consistent navbar on every page (TODO.md L3)
This commit is contained in:
+42
-107
@@ -37,18 +37,20 @@
|
||||
* collapsed Thinking block is restored with it; records without it (old
|
||||
* sessions) restore exactly as before, so no version bump. 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.
|
||||
* "New chat" (#new-chat-btn — bound by the shared header module,
|
||||
* phase 34 task 02) clears the key + the list back to the empty state.
|
||||
*
|
||||
* Steering notes (phase 15) let the owner tune how Brain answers: a
|
||||
* "Tune" button under every completed brain bubble (deflected included)
|
||||
* opens an inline form → POST /api/steering → the note is stored in
|
||||
* Postgres and injected into the system prompt of every subsequent turn
|
||||
* (the <tuning> section). Notes are listed newest-first in the header
|
||||
* "Tuning" panel (#steering-panel), where each can be deleted. Note text
|
||||
* is always rendered with textContent (XSS-safe), save/delete are
|
||||
* announced through a polite live region (#steering-announcer), and the
|
||||
* panel + count badge update on every change.
|
||||
* (the <tuning> section). The header "Tuning" panel (#steering-panel)
|
||||
* — toggle, list, per-note delete, count badge, announcer — is owned by
|
||||
* the shared header module (assets/header.js, phase 34); this file keeps
|
||||
* only the chat-specific per-bubble Tune button + inline form, whose
|
||||
* success path refreshes the panel (refreshSteering()) and announces
|
||||
* (announceSteering()) through the module. Note text is always rendered
|
||||
* with textContent (XSS-safe) in both places.
|
||||
*
|
||||
* Scroll (phase 18, owner choice 2026-08-23): the page auto-scrolls only
|
||||
* while the user is pinned to the bottom. NEAR_BOTTOM_PX (200px) covers
|
||||
@@ -72,7 +74,12 @@
|
||||
* All DOM ids match frontend/index.html.
|
||||
*/
|
||||
|
||||
import { fetchIsAdmin, initSharedHeader } from "./header.js";
|
||||
import {
|
||||
fetchIsAdmin,
|
||||
initSharedHeader,
|
||||
refreshSteering,
|
||||
announceSteering,
|
||||
} from "./header.js";
|
||||
import { openDocumentModal } from "./document-modal.js"; // phase 26: chips open the same-page modal
|
||||
|
||||
const messagesEl = document.querySelector("#messages");
|
||||
@@ -164,30 +171,24 @@ export function documentUrl(source, path, back = "/") {
|
||||
return url;
|
||||
}
|
||||
|
||||
/* ---------- steering notes (phase 15) ----------
|
||||
/* ---------- steering notes (phase 15; panel module-owned from phase 34) ----------
|
||||
*
|
||||
* The owner's tuning notes steer every future answer: they live in
|
||||
* Postgres (stateless API, A10) and the chat turn reads them into the
|
||||
* system prompt. UI contract: Tune button → inline form → save →
|
||||
* confirmation (or inline error, form kept); the header panel lists the
|
||||
* notes (newest first) with per-note delete.
|
||||
* confirmation (or inline error, form kept). The header panel — toggle,
|
||||
* list, per-note delete, count badge, announcer — is owned by the shared
|
||||
* header module (assets/header.js, phase 34 task 01); this page keeps
|
||||
* only the chat-specific part: the Tune button under every completed
|
||||
* brain bubble and its inline form. Save success refreshes the panel
|
||||
* through refreshSteering() and announces through announceSteering() —
|
||||
* both imported from header.js.
|
||||
*/
|
||||
const steeringToggle = document.querySelector("#steering-toggle");
|
||||
const steeringCount = document.querySelector("#steering-count");
|
||||
const steeringPanel = document.querySelector("#steering-panel");
|
||||
const steeringList = document.querySelector("#steering-list");
|
||||
const steeringEmpty = document.querySelector("#steering-empty");
|
||||
const steeringAnnouncer = document.querySelector("#steering-announcer");
|
||||
|
||||
const TUNE_ICON =
|
||||
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="15.5" cy="7" r="2.2"/><circle cx="9.5" cy="17" r="2.2"/></svg>';
|
||||
|
||||
let tuneSeq = 0; // unique ids for one open tune form's inputs
|
||||
|
||||
function announceSteering(message) {
|
||||
if (steeringAnnouncer) steeringAnnouncer.textContent = message;
|
||||
}
|
||||
|
||||
/* "Tune" button in the meta row of a completed brain bubble. Reuses the
|
||||
sources' .msg-meta row when it exists (role=list → the button joins as
|
||||
a listitem so ARIA stays valid); otherwise creates a plain meta row.
|
||||
@@ -279,7 +280,7 @@ function openTuneForm(wrap, toggleBtn) {
|
||||
saved.textContent = "Saved — future answers will follow this.";
|
||||
form.replaceWith(saved);
|
||||
announceSteering("Tuning note saved. Future answers will follow it.");
|
||||
await loadSteering(); // panel + count badge update
|
||||
await refreshSteering(); // header.js: panel + count badge update
|
||||
} catch {
|
||||
status.textContent = "Could not save the note — is the app reachable?";
|
||||
status.hidden = false;
|
||||
@@ -294,76 +295,6 @@ function openTuneForm(wrap, toggleBtn) {
|
||||
form.querySelector("textarea").focus();
|
||||
}
|
||||
|
||||
/* Panel: newest-first list (textContent — XSS-safe), per-note delete,
|
||||
empty text, and the header count badge. */
|
||||
async function loadSteering() {
|
||||
let notes = [];
|
||||
try {
|
||||
const r = await fetch("/api/steering");
|
||||
if (r.ok) notes = (await r.json()).notes || [];
|
||||
} catch { /* API unreachable: keep the last rendered list */ }
|
||||
renderSteeringPanel(notes);
|
||||
return notes;
|
||||
}
|
||||
|
||||
function renderSteeringPanel(notes) {
|
||||
if (!steeringList) return;
|
||||
steeringList.textContent = "";
|
||||
for (const n of notes) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "steering-note";
|
||||
const text = document.createElement("span");
|
||||
text.className = "steering-note-text";
|
||||
text.textContent = n.note; // rendered as text, never as HTML
|
||||
li.appendChild(text);
|
||||
const del = document.createElement("button");
|
||||
del.type = "button";
|
||||
del.className = "steering-delete";
|
||||
del.setAttribute("aria-label", `Delete tuning note: ${n.note}`);
|
||||
del.innerHTML =
|
||||
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 7h14M10 7V5h4v2M8.5 7l.7 12h5.6l.7-12"/></svg>';
|
||||
del.addEventListener("click", () => deleteSteeringNote(n.id, del));
|
||||
li.appendChild(del);
|
||||
steeringList.appendChild(li);
|
||||
}
|
||||
if (steeringEmpty) steeringEmpty.hidden = notes.length > 0;
|
||||
if (steeringCount) steeringCount.textContent = String(notes.length);
|
||||
}
|
||||
|
||||
async function deleteSteeringNote(id, btn) {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
if (r.status === 404) {
|
||||
announceSteering("That note was already removed.");
|
||||
await loadSteering();
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
announceSteering("Could not delete the note — try again.");
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
await loadSteering();
|
||||
announceSteering("Tuning note deleted.");
|
||||
} catch {
|
||||
announceSteering("Could not delete the note — is the app reachable?");
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setSteeringPanel(open) {
|
||||
if (!steeringPanel || !steeringToggle) return;
|
||||
steeringPanel.hidden = !open;
|
||||
steeringToggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
}
|
||||
if (steeringToggle && steeringPanel) {
|
||||
steeringToggle.addEventListener("click", () => {
|
||||
setSteeringPanel(steeringPanel.hidden);
|
||||
if (!steeringPanel.hidden) loadSteering(); // refresh when (re)opened
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- avatar glyphs (phase 08: emoji-free chrome) ----------
|
||||
* Inline SVG as string constants so the message renderer and the typing
|
||||
* indicator share exactly the same marks. currentColor lets the CSS theme
|
||||
@@ -819,10 +750,13 @@ function rememberBrainTurn(rawText, meta) {
|
||||
* reload) all moved to the shared header module (assets/header.js) —
|
||||
* initSharedHeader() does the header toggling on every page, and
|
||||
* fetchIsAdmin() is the single cached whoami, so this page still makes
|
||||
* exactly one request per load. applyAuthState keeps only the
|
||||
* chat-page-specific work (removing the tuning surface for anonymous
|
||||
* visitors) — idempotent alongside the header module's own link/button
|
||||
* toggling.
|
||||
* exactly one request per load.
|
||||
*
|
||||
* Phase 34: removing the tuning surface for anonymous visitors (toggle
|
||||
* + panel, "absent not hidden") also moved into initSharedHeader() —
|
||||
* the module owns the controls, so the module gates them. applyAuthState
|
||||
* keeps only the auth-pair toggling — idempotent alongside the header
|
||||
* module's own link/button toggling.
|
||||
*/
|
||||
const signInLink = document.querySelector("#sign-in-link");
|
||||
const signOutBtn = document.querySelector("#sign-out-btn");
|
||||
@@ -831,13 +765,8 @@ let isAdmin = false;
|
||||
function applyAuthState() {
|
||||
if (signInLink) signInLink.hidden = isAdmin;
|
||||
if (signOutBtn) signOutBtn.hidden = !isAdmin;
|
||||
if (!isAdmin && steeringToggle) {
|
||||
steeringToggle.remove();
|
||||
steeringPanel?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
const newChatBtn = document.querySelector("#new-chat-btn");
|
||||
function startNewChat() {
|
||||
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
|
||||
conversation = [];
|
||||
@@ -852,7 +781,11 @@ function startNewChat() {
|
||||
input.focus();
|
||||
sendStatus.textContent = "New chat started — previous conversation cleared.";
|
||||
}
|
||||
if (newChatBtn) newChatBtn.addEventListener("click", startNewChat);
|
||||
/* Phase 34 task 02: the #new-chat-btn binding is module-owned
|
||||
* (header.js, the SINGLE New chat binding) — on the chat page the
|
||||
* module dispatches window "bor:new-chat" and this page acts through
|
||||
* its own in-flight-turn guard + list reset. */
|
||||
window.addEventListener("bor:new-chat", startNewChat);
|
||||
|
||||
function showErrorBanner(detail) {
|
||||
banner.hidden = false;
|
||||
@@ -1052,13 +985,15 @@ window.addEventListener("pagehide", () => {
|
||||
Phase 14: the conversation then comes back exactly as left. Phase 19:
|
||||
the shared header module runs the whoami (cached — exactly one
|
||||
request per page load) and toggles the Sign in/out pair + the Sources
|
||||
nav link; applyAuthState() then applies the chat-page-only gating. */
|
||||
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. */
|
||||
(async () => {
|
||||
await initSharedHeader(); // header.js: whoami + Sign in/out + #nav-sources
|
||||
await initSharedHeader(); // header.js: whoami + Sign in/out + steering gate
|
||||
isAdmin = await fetchIsAdmin(); // the same cached promise — one whoami
|
||||
applyAuthState(); // chat page: the admin-only tuning surface
|
||||
applyAuthState(); // chat page: the auth pair (idempotent with header.js)
|
||||
restoreConversation();
|
||||
loadSuggestions();
|
||||
loadHealth();
|
||||
if (isAdmin) loadSteering(); // phase 15: panel + count badge (admin only)
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user