feat(chat): share a chat by link — anonymous read-only /shared/<token> page, share/unshare
This commit is contained in:
@@ -155,6 +155,28 @@
|
||||
* the error banner. Phase 14's local persistence is untouched: saving is
|
||||
* an additional, explicit action.
|
||||
*
|
||||
* 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
|
||||
* /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
|
||||
* saves AND shares (owner-locked). On success the ABSOLUTE share URL
|
||||
* (share_url resolved against the page origin) is copied:
|
||||
* navigator.clipboard.writeText in a try — a non-secure (http) homelab
|
||||
* origin rejects the clipboard, so the failure path renders the inline
|
||||
* fallback: a transient link field near the status line (an <a> styled
|
||||
* 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.
|
||||
*
|
||||
* All DOM ids match frontend/index.html.
|
||||
*/
|
||||
|
||||
@@ -178,6 +200,7 @@ 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 39: the display name resolves from one place — window.BOR_BRAND
|
||||
* (the classic assets/brand.js sets it at parse time; its /api/config
|
||||
@@ -1135,6 +1158,116 @@ async function saveCurrentChat() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- share the conversation (phase 51, owner-locked 2026-08-29) ---------- */
|
||||
|
||||
/* The share link's ABSOLUTE URL: the API reports the PATH
|
||||
* (/shared/<token>); the owner's own origin supplies the scheme/host —
|
||||
* a homelab http origin stays http (never assume https). */
|
||||
function absoluteShareUrl(shareUrl) {
|
||||
return new URL(shareUrl, window.location.origin).toString();
|
||||
}
|
||||
|
||||
/* Select every text node in an element — the link field's
|
||||
* select-on-focus (an <a> has no .select(); a range does the job).
|
||||
* Best-effort: selection failure only means the user copies by hand. */
|
||||
function selectAllInField(el) {
|
||||
try {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
} catch {
|
||||
/* selection is best-effort — the field still shows the full URL */
|
||||
}
|
||||
}
|
||||
|
||||
/* Clipboard copy with the owner-locked inline-link fallback: a
|
||||
* non-secure (http) homelab origin rejects navigator.clipboard, so the
|
||||
* failure path renders a TRANSIENT <a> link field near the status line
|
||||
* (appended to the composer, beside the send button that carries
|
||||
* #send-status) — input-like, it selects its full URL on focus (click
|
||||
* or Tab, then Ctrl/Cmd+C). One field at a time (a new offer replaces
|
||||
* the old). Returns true when the clipboard took it. */
|
||||
async function copyShareLinkWithFallback(absoluteUrl) {
|
||||
document.querySelectorAll(".share-link-fallback").forEach((el) => el.remove());
|
||||
try {
|
||||
await navigator.clipboard.writeText(absoluteUrl);
|
||||
return true;
|
||||
} catch {
|
||||
const field = document.createElement("a");
|
||||
field.className = "share-link-fallback";
|
||||
field.href = absoluteUrl; // carries the full URL (copy link address works too)
|
||||
field.textContent = absoluteUrl; // the URL is data — textContent, never innerHTML
|
||||
field.title = "Share link — click, then copy (Ctrl/Cmd+C)";
|
||||
field.addEventListener("focus", () => selectAllInField(field));
|
||||
composer.appendChild(field); // near the status line (inside the send button)
|
||||
field.focus({ preventScroll: true }); // selects the URL — ready to copy
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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
|
||||
* Save). The save-then-share branch: linked → POST
|
||||
* /api/chats/<id>/share (idempotent token); unlinked → POST /api/chats
|
||||
* with { messages, share: true } and link to the created id — one
|
||||
* 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
|
||||
* failure → the reachable? banner. The double-click guard releases in
|
||||
* the finally — never stale (PLAN §7.4). */
|
||||
async function shareCurrentChat() {
|
||||
if (!conversation.length) {
|
||||
sendStatus.textContent = "Nothing to share yet.";
|
||||
return;
|
||||
}
|
||||
if (shareBtn.disabled) return; // one share at a time (double-click guard)
|
||||
shareBtn.disabled = true;
|
||||
try {
|
||||
let shareUrl;
|
||||
if (currentChatId) {
|
||||
// Linked (already saved): the idempotent share — an existing
|
||||
// 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."
|
||||
);
|
||||
return;
|
||||
}
|
||||
shareUrl = (await res.json()).share_url;
|
||||
} else {
|
||||
// Unsaved: save AND share in ONE action (owner-locked) — the
|
||||
// server sets the 128-bit uuid4 token in the same commit.
|
||||
const res = await fetch("/api/chats", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
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."
|
||||
);
|
||||
return;
|
||||
}
|
||||
const created = await res.json();
|
||||
currentChatId = String(created.id); // one action saved AND shared: link
|
||||
shareUrl = created.share_url;
|
||||
}
|
||||
const copied = await copyShareLinkWithFallback(absoluteShareUrl(shareUrl));
|
||||
sendStatus.textContent = copied
|
||||
? "Share link copied."
|
||||
: "Share link ready — copy it from the field.";
|
||||
} catch {
|
||||
showErrorBanner("Couldn't share the conversation — is the app reachable?");
|
||||
} finally {
|
||||
shareBtn.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
|
||||
@@ -1550,6 +1683,9 @@ composer.addEventListener("submit", handleSend);
|
||||
* 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). */
|
||||
shareBtn?.addEventListener("click", shareCurrentChat);
|
||||
|
||||
/* Navigate-away save point (phase 20, owner choice 2026-08-24 A1):
|
||||
* leaving the chat mid-turn would otherwise drop the in-flight
|
||||
@@ -1585,6 +1721,7 @@ window.addEventListener("pagehide", () => {
|
||||
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 50: /?chat=<id> (valid uuid + admin) boots into the saved
|
||||
// conversation; every other outcome falls through to the local restore.
|
||||
const openedSaved = await restoreSavedChatFromUrl();
|
||||
|
||||
@@ -106,9 +106,20 @@ export async function initSharedHeader() {
|
||||
// a query-safe "/…" string (never "//"; ? # and spaces stay
|
||||
// percent-encoded in it), so it rides in next= as-is — the same shape
|
||||
// the static fallbacks use (login.js safeNext re-validates it).
|
||||
//
|
||||
// Phase 51 (owner-locked 2026-08-29, TODO.md L6): the ONE exception —
|
||||
// on the NESTED /shared/<token> page "where you were" is a public
|
||||
// link, not a place in the app: the rewrite stays at the APP ROOT
|
||||
// ("/"), so a guest signing in from a shared page lands in the chat
|
||||
// (the static ?next=/ fallback in shared.html matches — the rewrite
|
||||
// only ever KEEPS it there). A signed-in admin on the shared page
|
||||
// never sees the link (hidden = admin), so this only shapes the
|
||||
// guest experience.
|
||||
const nextPath = window.location.pathname || "/";
|
||||
const signInNext = nextPath.startsWith("/shared/") ? "/" : nextPath;
|
||||
document.querySelectorAll(".sign-in-link").forEach(link => {
|
||||
link.hidden = admin;
|
||||
link.href = "/login.html?next=" + (window.location.pathname || "/");
|
||||
link.href = "/login.html?next=" + signInNext;
|
||||
});
|
||||
document.querySelectorAll(".sign-out-btn").forEach(btn => { btn.hidden = !admin; });
|
||||
const navSources = document.querySelector("#nav-sources");
|
||||
|
||||
+211
-5
@@ -3,21 +3,40 @@
|
||||
* 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:
|
||||
* Wires the admin-only `GET /api/chats` + `POST /api/chats/<id>/share`
|
||||
* + `POST /api/chats/<id>/unshare` + `DELETE /api/chats/<id>`
|
||||
* endpoints (phase 50 task 02; phase 51 task 01+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
|
||||
* • Share — phase 51 (owner-locked 2026-08-29, TODO.md L6): the
|
||||
* row's share state, rendered from the list's OWN share_url (the
|
||||
* GET /api/chats endpoint populates it — no second fetch per row).
|
||||
* Three states: unshared → [Create link] (POST share → the cell
|
||||
* re-renders shared + the link is offered for copying); shared →
|
||||
* [Copy] [Unshare]; confirming → the inline two-step "Unshare?
|
||||
* [Yes] [No]" (the phase-50 Delete-confirm pattern + CSS — no
|
||||
* native dialog) — Yes POSTs /api/chats/<id>/unshare (revokes),
|
||||
* the cell re-renders unshared + the live region;
|
||||
* • Actions — Delete, 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.
|
||||
*
|
||||
* The share copy has the owner-locked inline-link fallback: a
|
||||
* non-secure (http) homelab origin rejects navigator.clipboard, so the
|
||||
* offer renders a transient .share-link-fallback <a> field (input-like,
|
||||
* selects its full URL on focus) in the row's share cell — this file
|
||||
* keeps its OWN copy of the ~10-line helper (the per-page duplication
|
||||
* house style; app.js keeps the chat page's) rather than a new shared
|
||||
* module.
|
||||
*
|
||||
* 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
|
||||
@@ -86,6 +105,13 @@ function makeRow(chat) {
|
||||
updatedTd.textContent = fmtDate(chat.updated_at);
|
||||
tr.appendChild(updatedTd);
|
||||
|
||||
// Phase 51: the Share cell (between Updated and Actions) — the
|
||||
// three-state share control (unshared / shared / confirming-unshare).
|
||||
const shareTd = document.createElement("td");
|
||||
shareTd.className = "history-share-cell";
|
||||
shareTd.appendChild(makeShareControl(chat));
|
||||
tr.appendChild(shareTd);
|
||||
|
||||
const actionsTd = document.createElement("td");
|
||||
actionsTd.className = "history-actions-cell";
|
||||
actionsTd.appendChild(makeDeleteControl(chat, tr));
|
||||
@@ -169,6 +195,186 @@ async function confirmDelete(chat, row, yesBtn, restoreDelete) {
|
||||
announce(`Deleted "${chat.title}".`);
|
||||
}
|
||||
|
||||
/* ---------- share column (phase 51, owner-locked 2026-08-29) ---------- */
|
||||
|
||||
/* Select every text node in the link field (an <a> has no .select();
|
||||
a range does the job) — best-effort: a selection failure only means
|
||||
the user copies by hand. */
|
||||
function selectAllInField(el) {
|
||||
try {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
} catch {
|
||||
/* selection is best-effort — the field still shows the full URL */
|
||||
}
|
||||
}
|
||||
|
||||
/* Clipboard + the owner-locked inline-link fallback: a non-secure
|
||||
(http) homelab origin rejects navigator.clipboard, so the failure
|
||||
path renders a TRANSIENT <a> link field in the row's share cell —
|
||||
input-like, it selects its full URL on focus (click or Tab, then
|
||||
Ctrl/Cmd+C). One field at a time (a new offer replaces the old).
|
||||
Returns true when the clipboard took it. (The per-page duplication
|
||||
house style — this is history.js's OWN copy of the ~10-line helper;
|
||||
app.js keeps the chat page's, no new shared module.) */
|
||||
async function copyShareLink(cell, absoluteUrl) {
|
||||
cell.querySelectorAll(".share-link-fallback").forEach((el) => el.remove());
|
||||
try {
|
||||
await navigator.clipboard.writeText(absoluteUrl);
|
||||
return true;
|
||||
} catch {
|
||||
const field = document.createElement("a");
|
||||
field.className = "share-link-fallback";
|
||||
field.href = absoluteUrl; // carries the full URL (copy link address works too)
|
||||
field.textContent = absoluteUrl; // the URL is data — textContent, never innerHTML
|
||||
field.title = "Share link — click, then copy (Ctrl/Cmd+C)";
|
||||
field.addEventListener("focus", () => selectAllInField(field));
|
||||
cell.appendChild(field);
|
||||
field.focus({ preventScroll: true }); // selects the URL — ready to copy
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* The unshared state: the [Create link] button (a failed share keeps
|
||||
the cell here — Create link is retryable). */
|
||||
function renderShareUnshared(chat, cell) {
|
||||
const create = document.createElement("button");
|
||||
create.type = "button";
|
||||
create.className = "history-share-create";
|
||||
create.setAttribute("aria-label", `Create share link: ${chat.title}`);
|
||||
create.textContent = "Create link";
|
||||
create.addEventListener("click", () => void createShareLink(chat, cell, create));
|
||||
cell.replaceChildren(create);
|
||||
}
|
||||
|
||||
/* The shared state: [Copy] [Unshare]. Unshare is the inline two-step
|
||||
(the phase-50 Delete-confirm pattern — same .history-confirm-* CSS,
|
||||
focus moves to Yes so the confirm is keyboard-reachable); No or a
|
||||
failed request restores this state (retryable). */
|
||||
function renderShareShared(chat, cell) {
|
||||
const copy = document.createElement("button");
|
||||
copy.type = "button";
|
||||
copy.className = "history-share-copy";
|
||||
copy.setAttribute("aria-label", `Copy share link: ${chat.title}`);
|
||||
copy.textContent = "Copy";
|
||||
copy.addEventListener("click", () => void copyRowShareLink(chat, cell));
|
||||
const unshare = document.createElement("button");
|
||||
unshare.type = "button";
|
||||
unshare.className = "history-unshare";
|
||||
unshare.setAttribute("aria-label", `Unshare saved chat: ${chat.title}`);
|
||||
unshare.textContent = "Unshare";
|
||||
function restoreShared() {
|
||||
cell.replaceChildren(copy, unshare);
|
||||
unshare.focus({ preventScroll: true }); // focus returns to the (restored) control
|
||||
}
|
||||
unshare.addEventListener("click", () => {
|
||||
const label = document.createElement("span");
|
||||
label.className = "history-confirm-text";
|
||||
label.textContent = "Unshare?";
|
||||
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", () => void confirmUnshare(chat, cell, yes, restoreShared));
|
||||
no.addEventListener("click", restoreShared);
|
||||
cell.replaceChildren(label, yes, no);
|
||||
yes.focus({ preventScroll: true }); // the confirm pair takes over the focus
|
||||
});
|
||||
cell.replaceChildren(copy, unshare);
|
||||
}
|
||||
|
||||
/* The Share cell (phase 51): the span the row's Share <td> carries.
|
||||
The shipped state comes from the row's share_url (the list endpoint
|
||||
populates it — no second fetch): shared → Copy + Unshare, unshared →
|
||||
Create link. No share action ever removes the ROW (only Delete
|
||||
does) — the cell just re-renders between its states. */
|
||||
function makeShareControl(chat) {
|
||||
const cell = document.createElement("span");
|
||||
cell.className = "history-share";
|
||||
if (chat.share_url) {
|
||||
renderShareShared(chat, cell);
|
||||
} else {
|
||||
renderShareUnshared(chat, cell);
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
/* Create the link: POST /api/chats/<id>/share → the response's
|
||||
share_url becomes the row's data (chat.share_url — the later Copy
|
||||
uses it), the cell re-renders to the shared state, and the ABSOLUTE
|
||||
link (the row's own origin supplies the scheme/host) is offered for
|
||||
copying — clipboard → the inline-field fallback in the cell. A
|
||||
non-2xx (a 404 — the row was deleted behind our back — or 5xx) or a
|
||||
network error keeps the unshared state (Create link re-enabled,
|
||||
retryable) and lands the error line. */
|
||||
async function createShareLink(chat, cell, createBtn) {
|
||||
createBtn.disabled = true; // no double-fire while the request is in flight
|
||||
let r;
|
||||
try {
|
||||
r = await fetch(`/api/chats/${chat.id}/share`, { method: "POST" });
|
||||
} catch {
|
||||
announce(`Couldn't share "${chat.title}" — is the app reachable?`);
|
||||
createBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
announce(`Couldn't share "${chat.title}" — try again.`);
|
||||
createBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const { share_url } = await r.json();
|
||||
chat.share_url = share_url; // the row is shared from now on
|
||||
renderShareShared(chat, cell);
|
||||
const copied = await copyShareLink(
|
||||
cell,
|
||||
new URL(share_url, window.location.origin).toString(),
|
||||
);
|
||||
announce(copied ? "Share link copied." : "Share link ready — copy it from the field.");
|
||||
}
|
||||
|
||||
/* Copy (shared state): re-copy the row's share_url — the per-page
|
||||
clipboard + fallback helper; the live region lands the outcome. */
|
||||
async function copyRowShareLink(chat, cell) {
|
||||
const copied = await copyShareLink(
|
||||
cell,
|
||||
new URL(chat.share_url, window.location.origin).toString(),
|
||||
);
|
||||
announce(copied ? "Share link copied." : "Share link ready — copy it from the field.");
|
||||
}
|
||||
|
||||
/* The confirmed unshare: POST /api/chats/<id>/unshare → the token is
|
||||
NULL (revoked — the public link 404s from now on), the cell
|
||||
re-renders to the unshared state (Create link) and the live region
|
||||
gets `Unshared "<title>".` A non-2xx / a network error keeps the
|
||||
shared state (restoreShared — Copy + Unshare, retryable) and lands
|
||||
the error line. */
|
||||
async function confirmUnshare(chat, cell, yesBtn, restoreShared) {
|
||||
yesBtn.disabled = true; // no double-fire while the request is in flight
|
||||
let r;
|
||||
try {
|
||||
r = await fetch(`/api/chats/${chat.id}/unshare`, { method: "POST" });
|
||||
} catch {
|
||||
announce(`Couldn't unshare "${chat.title}" — is the app reachable?`);
|
||||
restoreShared();
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
announce(`Couldn't unshare "${chat.title}" — try again.`);
|
||||
restoreShared();
|
||||
return;
|
||||
}
|
||||
chat.share_url = null; // revoked: the row is unshared again
|
||||
renderShareUnshared(chat, cell);
|
||||
announce(`Unshared "${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() {
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
/* Brain of Reese — the anonymous shared conversation page (phase 51,
|
||||
* task 03).
|
||||
*
|
||||
* /shared/<token> (owner-locked 2026-08-29, TODO.md L6): anyone with
|
||||
* the link sees the conversation READ-ONLY — zero interactive controls
|
||||
* (no composer, no Save/Share/Tune/Retry, no document access). The
|
||||
* page fetches GET /api/shared/<token> (public — the token IS the
|
||||
* credential, no admin dependency) and renders the SAME record shape
|
||||
* the chat page uses (phase 14 bor.chat.v1 — { who, text, sources?,
|
||||
* deflected?, suggestions?, thinking?, tools?, stopped? }):
|
||||
*
|
||||
* • user → the .msg.user bubble (markdown, escape-first);
|
||||
* • brain → the .msg.brain bubble: the optional thinking block
|
||||
* restored COLLAPSED (the phase-17 restore convention — a guest
|
||||
* can still expand it; reading is not mutating), the tool lines
|
||||
* in saved order (phase 37), the is-deflected treatment + the
|
||||
* "Maybe try" chips as PLAIN SPAN text (a guest tapping a chip
|
||||
* has nowhere to go — owner-locked zero controls), the source
|
||||
* chips as PLAIN TEXT spans (no href, no modal wiring — the
|
||||
* documents API is admin-only, so a guest cannot open documents),
|
||||
* and the stopped note (phase 48) when the turn was user-stopped.
|
||||
*
|
||||
* Per-page duplication house style (history.js keeps its own clipboard
|
||||
* helper, the chat page's stays in app.js): this file carries its own
|
||||
* small copies of the chat page's message-fragment builders — the
|
||||
* thinking block, the tool lines, the stopped note, the chip rows.
|
||||
* Nothing is imported from app.js (a page script is never imported by
|
||||
* another page; header.js is the only cross-page module).
|
||||
*
|
||||
* Failure contract: a malformed or missing token in the URL → the
|
||||
* invalid state shows immediately with NO fetch of any kind (not even
|
||||
* whoami — the header ships in its guest state, which is already
|
||||
* correct); a 404 (wrong or revoked token), a network failure, or a
|
||||
* malformed body → the invalid state (the h1 keeps its fallback), no
|
||||
* data is rendered, and there is no error banner on this page (zero
|
||||
* controls — the muted invalid card is the whole failure UI).
|
||||
*
|
||||
* The header works for guests: initSharedHeader() runs the cached
|
||||
* whoami (anonymous → the admin-only links stay hidden, the Sign in
|
||||
* link is shown) and rewrites ?next= to the current pathname for a
|
||||
* signed-in admin; the guest's static fallback is ?next=/ — a guest
|
||||
* signing in from a shared page returns to the app root (see
|
||||
* shared.html).
|
||||
*
|
||||
* All DOM ids match frontend/shared.html.
|
||||
*/
|
||||
|
||||
import { initSharedHeader } from "./header.js";
|
||||
|
||||
const titleEl = document.querySelector("#shared-title");
|
||||
const noteEl = document.querySelector(".shared-note");
|
||||
const messagesEl = document.querySelector("#messages");
|
||||
const invalidEl = document.querySelector("#shared-invalid");
|
||||
|
||||
/* 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
|
||||
* fetch refreshes it). Read LAZILY (a function, not a const string):
|
||||
* the note set after the fetch lands carries the configured name; the
|
||||
* literal is only the no-config fallback. */
|
||||
const brand = () => window.BOR_BRAND || "Brain of Reese";
|
||||
|
||||
/* ---------- the token (from the URL) ----------
|
||||
* The last path segment of /shared/<token>. A malformed or missing
|
||||
* token (a path without a final segment, or a non-uuid segment) →
|
||||
* null: the boot shows the invalid state immediately and makes NO
|
||||
* fetch of any kind (the server's page route 404s a hand-typed
|
||||
* /shared/garbage anyway — the client gate keeps the no-fetch rule
|
||||
* and renders the page's own invalid state instead of a JSON error). */
|
||||
const TOKEN_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function parseSharedToken() {
|
||||
const segments = window.location.pathname.split("/").filter(Boolean);
|
||||
const last = segments[segments.length - 1] || "";
|
||||
return TOKEN_RE.test(last) ? last : null;
|
||||
}
|
||||
|
||||
/* ---------- the invalid / revoked state ----------
|
||||
* The one failure UI on the page: the centered muted card (ship-
|
||||
* hidden in the markup, revealed here). The h1 keeps its static
|
||||
* fallback, the conversation section stays empty — no data rendered,
|
||||
* no banner. */
|
||||
export function showInvalid() {
|
||||
if (invalidEl) invalidEl.hidden = false;
|
||||
}
|
||||
|
||||
/* ---------- avatar glyphs (duplicated from app.js, phase 08) ----------
|
||||
* Inline SVG as string constants so the message renderer shares the
|
||||
* exact marks the chat page uses. currentColor lets the CSS theme the
|
||||
* stroke (brand-ink for Brain, ink-soft for the user). */
|
||||
const BRAIN_AVATAR =
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6.5" y="6.5" width="11" height="11" rx="2.5"/><circle cx="12" cy="12" r="1.9" fill="currentColor" stroke="none"/><path d="M9.5 6.5V3.8M14.5 6.5V3.8M9.5 20.2v-2.7M14.5 20.2v-2.7M6.5 9.5H3.8M6.5 14.5H3.8M20.2 9.5h-2.7M20.2 14.5h-2.7"/></svg>';
|
||||
|
||||
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 (read-only) ----------
|
||||
* The SAME .msg/.msg-body/.bubble structure the chat page renders, so
|
||||
* the existing CSS applies unchanged. No scroll intent (a guest lands
|
||||
* where the browser puts them — no composer to reveal), no empty
|
||||
* state to hide (the section is empty by construction until records
|
||||
* are appended). */
|
||||
function addSharedMessage(who, html) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = `msg ${who}`;
|
||||
wrap.innerHTML = `
|
||||
<span class="avatar" aria-hidden="true">${who === "brain" ? BRAIN_AVATAR : USER_AVATAR}</span>
|
||||
<div class="msg-body">
|
||||
<div class="bubble">${html}</div>
|
||||
</div>`;
|
||||
messagesEl.appendChild(wrap);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/* The thinking block (phase 17) — the local copy of the chat page's
|
||||
* restore path: the model's reasoning ABOVE the answer bubble,
|
||||
* restored COLLAPSED (the phase-17 restore convention — the live path
|
||||
* opens it while streaming; a shared chat is a finished conversation,
|
||||
* so it lands closed). Native details/summary — expanding is reading,
|
||||
* not mutating. The reasoning text is stored RAW, so it goes through
|
||||
* the same escape-first global renderMarkdown as the answer. */
|
||||
function addThinkingBlock(wrap, thinking) {
|
||||
const body = wrap.querySelector(".msg-body");
|
||||
if (!body) return;
|
||||
const block = document.createElement("details");
|
||||
block.className = "thinking";
|
||||
block.open = false; // restored COLLAPSED
|
||||
const summary = document.createElement("summary");
|
||||
summary.textContent = "Thinking";
|
||||
const textEl = document.createElement("div");
|
||||
textEl.className = "thinking-text";
|
||||
textEl.innerHTML = renderMarkdown(thinking); // escape-first, XSS-safe
|
||||
block.append(summary, textEl);
|
||||
body.insertBefore(block, body.querySelector(".bubble"));
|
||||
}
|
||||
|
||||
/* Tool-call lines (phase 37) — the local copy of the chat page's
|
||||
* appendToolLine: one visible "calling tool" row per saved
|
||||
* {name, argument} record, in saved order, above the answer. The
|
||||
* path argument goes through textContent, so nothing HTML-shaped can
|
||||
* come from storage. Lines are not interactive (no focus targets).
|
||||
* The two content marks (the read glyph / the list glyph) are the
|
||||
* exact app.js template strings — the frontend emoji guard strips
|
||||
* precisely those two literals in this file, as in app.js. */
|
||||
function addToolLines(wrap, tools) {
|
||||
if (!Array.isArray(tools) || !tools.length) return;
|
||||
const body = wrap.querySelector(".msg-body");
|
||||
if (!body) return;
|
||||
const container = document.createElement("div");
|
||||
container.className = "tool-calls";
|
||||
container.setAttribute("role", "list");
|
||||
container.setAttribute("aria-label", "Tool calls");
|
||||
for (const t of tools) {
|
||||
if (!t || typeof t.name !== "string") continue;
|
||||
const line = document.createElement("span");
|
||||
line.className = "tool-call";
|
||||
line.setAttribute("role", "listitem");
|
||||
const argument =
|
||||
typeof t.argument === "string" && t.argument ? t.argument : null;
|
||||
if (t.name === "read_document" && argument) {
|
||||
line.textContent = "📄 Reading ";
|
||||
const code = document.createElement("code");
|
||||
code.textContent = argument; // the path is data, never markup
|
||||
line.appendChild(code);
|
||||
} else {
|
||||
line.textContent = "🔎 Listing documents";
|
||||
}
|
||||
container.appendChild(line);
|
||||
}
|
||||
body.insertBefore(container, body.querySelector(".bubble"));
|
||||
}
|
||||
|
||||
/* "Maybe try:" chips under a deflected bubble (honesty gate, phase
|
||||
* 04) — PLAIN SPAN text, not buttons (owner-locked 2026-08-29: a
|
||||
* guest tapping a chip has nowhere to go — zero interactive
|
||||
* controls). Same .suggestion-chip pill look as the chat page; the
|
||||
* shared-page CSS kills the pointer (pointer-events: none, scoped to
|
||||
* .shared-shell — the chat page's interactive chips are untouched). */
|
||||
function addMaybeTry(wrap, suggestions) {
|
||||
if (!Array.isArray(suggestions) || !suggestions.length) return;
|
||||
const body = wrap.querySelector(".msg-body");
|
||||
if (!body) return;
|
||||
const group = document.createElement("div");
|
||||
group.className = "maybe-try";
|
||||
group.setAttribute("role", "list");
|
||||
group.setAttribute("aria-label", "Maybe try");
|
||||
const label = document.createElement("span");
|
||||
label.className = "visually-hidden";
|
||||
label.textContent = "Maybe try:";
|
||||
group.appendChild(label);
|
||||
for (const item of suggestions) {
|
||||
const text = String(item || "").trim();
|
||||
if (!text) continue;
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "suggestion-chip";
|
||||
chip.setAttribute("role", "listitem");
|
||||
chip.textContent = text; // raw text — never markup
|
||||
group.appendChild(chip);
|
||||
}
|
||||
body.appendChild(group);
|
||||
}
|
||||
|
||||
/* Source chips (mono, source/path) under a brain bubble — PLAIN TEXT
|
||||
* spans: no href, no modal wiring, no click handler (owner-locked:
|
||||
* guests cannot open documents — the documents API is admin-only,
|
||||
* phase 16). Same .source-chip pill look as the chat page; the
|
||||
* shared-page CSS kills the pointer, scoped to .shared-shell. */
|
||||
function addSources(wrap, sources) {
|
||||
if (!Array.isArray(sources) || !sources.length) return;
|
||||
const body = wrap.querySelector(".msg-body");
|
||||
if (!body) return;
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "msg-meta";
|
||||
meta.setAttribute("role", "list");
|
||||
meta.setAttribute("aria-label", "Sources");
|
||||
for (const s of sources) {
|
||||
if (!s || typeof s.source !== "string" || typeof s.path !== "string") continue;
|
||||
const label = `${s.source}/${s.path}`;
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "source-chip";
|
||||
chip.setAttribute("role", "listitem");
|
||||
chip.textContent = label; // the path is data — never markup
|
||||
chip.title = label;
|
||||
meta.appendChild(chip);
|
||||
}
|
||||
body.appendChild(meta);
|
||||
}
|
||||
|
||||
/* The "Stopped" note (phase 48) — the local copy of the chat page's
|
||||
* appendStoppedNote: the non-interactive meta-row mark on a
|
||||
* user-stopped brain bubble (the partial answer is what the owner
|
||||
* shared). Reuses the .msg-meta row when one exists (the source
|
||||
* chips' row) so the note joins it as a listitem, ARIA-valid. */
|
||||
function addStoppedNote(wrap) {
|
||||
const body = wrap?.querySelector?.(".msg-body");
|
||||
if (!body) return;
|
||||
let meta = body.querySelector(".msg-meta");
|
||||
if (!meta) {
|
||||
meta = document.createElement("div");
|
||||
meta.className = "msg-meta";
|
||||
body.appendChild(meta);
|
||||
}
|
||||
if (meta.querySelector(".stopped-note")) return; // one per bubble
|
||||
const note = document.createElement("span");
|
||||
note.className = "stopped-note";
|
||||
if (meta.getAttribute("role") === "list") note.setAttribute("role", "listitem");
|
||||
note.innerHTML =
|
||||
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><rect x="6.5" y="6.5" width="11" height="11" rx="2"/></svg>';
|
||||
const label = document.createElement("span");
|
||||
label.textContent = "Stopped";
|
||||
note.appendChild(label);
|
||||
meta.appendChild(note);
|
||||
}
|
||||
|
||||
/* One stored record through the SAME .msg structure the chat page
|
||||
* uses (pixel-parity with the chat page's restore path): user → the
|
||||
* .msg.user bubble; brain → the .msg.brain bubble with the optional
|
||||
* thinking block (restored COLLAPSED — phase 17), the tool lines,
|
||||
* the deflection treatment + the plain-text "Maybe try" chips, the
|
||||
* plain-text source chips, and the stopped note. NO interactive
|
||||
* markup is ever created here — no buttons, no forms, no links, no
|
||||
* click handlers (owner-locked: zero controls). Markdown goes
|
||||
* through the global escape-first renderMarkdown (markdown.js): the
|
||||
* stored payloads are raw text, so the renderer's XSS safety applies
|
||||
* unchanged. */
|
||||
function renderSharedMessage(m) {
|
||||
if (m.who === "user") {
|
||||
addSharedMessage("user", renderMarkdown(m.text));
|
||||
return;
|
||||
}
|
||||
const wrap = addSharedMessage("brain", renderMarkdown(m.text));
|
||||
if (typeof m.thinking === "string" && m.thinking) {
|
||||
addThinkingBlock(wrap, m.thinking);
|
||||
}
|
||||
addToolLines(wrap, m.tools);
|
||||
if (m.deflected) {
|
||||
wrap.classList.add("is-deflected");
|
||||
addMaybeTry(wrap, m.suggestions);
|
||||
}
|
||||
addSources(wrap, m.sources);
|
||||
if (m.stopped) addStoppedNote(wrap);
|
||||
}
|
||||
|
||||
/* ---------- the public read ----------
|
||||
* GET /api/shared/<token> — no admin dependency (the token IS the
|
||||
* credential). Returns the SharedChatOut snapshot (title + messages)
|
||||
* or null: a 404 (wrong or revoked token — one message server-side,
|
||||
* no enumeration), a network failure, or a malformed body all
|
||||
* collapse to null → the invalid state. */
|
||||
async function fetchSharedChat(token) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`/api/shared/${token}`);
|
||||
} catch {
|
||||
return null; // network failure
|
||||
}
|
||||
if (!res.ok) return null; // 404 (wrong/revoked) / 5xx
|
||||
let data;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
return null; // malformed body
|
||||
}
|
||||
return data && Array.isArray(data.messages) ? data : null;
|
||||
}
|
||||
|
||||
/* The 200 path: the h1 gets the shared chat's title (the static
|
||||
* fallback stays when the title is missing/blank) and every record
|
||||
* renders through renderSharedMessage. The same defensive filter as
|
||||
* the chat page's restore keeps a corrupted stored row from
|
||||
* poisoning the render (nothing HTML-shaped, ever). */
|
||||
function renderSharedChat(data) {
|
||||
const title = typeof data.title === "string" ? data.title.trim() : "";
|
||||
if (title) titleEl.textContent = title;
|
||||
const messages = data.messages.filter(
|
||||
(m) =>
|
||||
m &&
|
||||
(m.who === "user" || m.who === "brain") &&
|
||||
typeof m.text === "string" &&
|
||||
m.text.length > 0
|
||||
);
|
||||
for (const m of messages) renderSharedMessage(m);
|
||||
}
|
||||
|
||||
/* Boot: the brand note first (call-time resolution — the classic
|
||||
* brand.js already set the synchronous default), then the token. A
|
||||
* malformed or missing token shows the invalid state immediately —
|
||||
* NO fetch of any kind (not even whoami: the header ships in its
|
||||
* guest state, which is already correct for a bad URL). A well-
|
||||
* formed token runs the shared header init (guests: whoami
|
||||
* anonymous, the admin-only links stay hidden) and then the public
|
||||
* read; a null read (404 / network / malformed) shows the invalid
|
||||
* state, and a 200 renders the conversation read-only. */
|
||||
(async () => {
|
||||
if (noteEl) noteEl.textContent = `Shared via ${brand()} — read-only.`;
|
||||
const token = parseSharedToken();
|
||||
if (!token) {
|
||||
showInvalid();
|
||||
return;
|
||||
}
|
||||
await initSharedHeader();
|
||||
const data = await fetchSharedChat(token);
|
||||
if (!data) {
|
||||
showInvalid();
|
||||
return;
|
||||
}
|
||||
renderSharedChat(data);
|
||||
})();
|
||||
@@ -338,6 +338,37 @@ html::after {
|
||||
the whole control below 640px (mirrored in the ≤640 block below). */
|
||||
.save-chat-btn svg { width: 16px; height: 16px; display: none; }
|
||||
|
||||
/* 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
|
||||
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). */
|
||||
.share-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;
|
||||
}
|
||||
.share-chat-btn:hover { background: #f55a72; color: var(--bg); }
|
||||
/* The link mark is hidden on desktop (the label carries the pill); it
|
||||
is the whole control below 640px (mirrored in the ≤640 block
|
||||
below). */
|
||||
.share-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.
|
||||
@@ -1897,6 +1928,60 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
cursor: pointer;
|
||||
}
|
||||
.history-confirm-no:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
||||
/* Share column (phase 51, owner-locked 2026-08-29): the Tune/Retry-
|
||||
family ghost buttons — Create link (unshared) and Copy (shared);
|
||||
Unshare is the .history-unshare ghost and its two-step confirm reuses
|
||||
the .history-confirm-* pair CSS above (the phase-50 pattern). The
|
||||
row-action language of .history-delete: --line border, transparent
|
||||
fill, ink-soft (>=5.1:1), ≥44px comfortable target, focus-visible
|
||||
via the global 3px rule; hover takes the brand pair (6.9:1). */
|
||||
.history-share { display: inline-flex; align-items: center; gap: 0.4rem; flex-wrap: nowrap; }
|
||||
.history-share-cell { white-space: nowrap; }
|
||||
.history-share-create,
|
||||
.history-share-copy,
|
||||
.history-unshare {
|
||||
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-share-create:hover:not(:disabled),
|
||||
.history-share-copy:hover:not(:disabled) { background: var(--brand-soft); color: var(--brand-ink); }
|
||||
.history-unshare:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
|
||||
.history-share-create:disabled,
|
||||
.history-share-copy:disabled,
|
||||
.history-unshare:disabled { opacity: 0.5; cursor: wait; }
|
||||
/* The share link's inline fallback field (phase 51, owner-locked): a
|
||||
non-secure (http) origin rejects the clipboard, so the full URL is
|
||||
offered as an input-like <a> that selects itself on focus — click or
|
||||
Tab, then Ctrl/Cmd+C. Mono (the URL is data), surface fill, --line
|
||||
border; truncates with an ellipsis at narrow widths (the full URL is
|
||||
the title + the text selection). Ink on surface ≈13.8:1. */
|
||||
.share-link-fallback {
|
||||
display: inline-block;
|
||||
max-width: 14rem;
|
||||
padding: 0.35rem 0.55rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.78rem;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.share-link-fallback:hover { border-color: var(--brand); }
|
||||
.share-link-fallback:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
|
||||
/* Empty-state row: the muted centered message at full table width
|
||||
(the .git-sources-empty language, inline in the table). */
|
||||
.history-empty-row td {
|
||||
@@ -1906,6 +1991,61 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ---------- Shared page (phase 51, task 03) ----------
|
||||
/shared/<token>: the anonymous read-only conversation (owner-locked
|
||||
2026-08-29, TODO.md L6). The shell maps to the PLAN §7 centered
|
||||
46rem chat column — the conversation reads exactly like the chat
|
||||
page (the .msg/.bubble/.thinking/.tool-calls/.msg-meta rules apply
|
||||
unchanged) with NO composer, so the column contract holds for a
|
||||
guest. Zero interactive controls (owner-locked): the chips are
|
||||
plain text, so the pill families' pointer treatments are switched
|
||||
off IN THIS SCOPE ONLY — the chat page's interactive chips keep
|
||||
their styles untouched. Every pair reuses the Phase-08 AA palette
|
||||
(ink-soft ≥6.9:1 on surface/bg, brand-ink on brand-soft 6.9:1);
|
||||
:focus-visible via the global 3px outline rule. No CDN, system
|
||||
fonts. */
|
||||
.shared-shell {
|
||||
width: 100%;
|
||||
max-width: 46rem; /* the PLAN §7 centered chat column */
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
}
|
||||
/* Page title (JS-filled with the shared chat's title; the static
|
||||
fallback is "Shared conversation") — the page-head h1 size. */
|
||||
#shared-title { margin: 0; font-size: 1.7rem; }
|
||||
/* The muted meta line under the h1 ("Shared via … — read-only."):
|
||||
ink-soft on the page bg ≥8.6:1, the page-sub language. */
|
||||
.shared-note {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
/* The invalid / revoked state: a centered muted card (the not-found
|
||||
language — surface fill, --line border, italic ink-soft). Revealed
|
||||
by shared.js for a malformed token (no fetch) or a failed read. */
|
||||
#shared-invalid {
|
||||
padding: 1.4rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--ink-soft);
|
||||
font-style: italic;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
/* Static chips: a guest's "Maybe try" and source chips are TEXT — no
|
||||
pointer, no hover, no cursor (owner-locked zero controls). The pill
|
||||
look is kept; the interactivity is scoped off here and only here
|
||||
(pointer-events: none also makes the :hover rules unreachable). */
|
||||
.shared-shell .suggestion-chip,
|
||||
.shared-shell .source-chip {
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---------- 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 /
|
||||
@@ -2445,12 +2585,19 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
.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). */
|
||||
.share-chat-btn { padding: 0.4rem 0.3rem; }
|
||||
.share-chat-label { display: none; }
|
||||
.share-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; }
|
||||
.chat-shell .share-chat-label { display: inline; }
|
||||
.chat-shell .share-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; }
|
||||
@@ -2564,6 +2711,12 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
the two-step confirm pair fits the phone width. */
|
||||
.history-actions-cell { white-space: normal; }
|
||||
.history-actions { flex-wrap: wrap; }
|
||||
/* Phase 51: the shared page squeezes like the chat column — the
|
||||
title and the note step down (the empty-state-title family); the
|
||||
shell keeps its 46rem column (it is already the narrowest box on
|
||||
the page) and .msg-body's 92% override above applies. */
|
||||
#shared-title { font-size: 1.35rem; }
|
||||
.shared-note { font-size: 0.88rem; }
|
||||
.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
|
||||
|
||||
@@ -135,7 +135,9 @@
|
||||
|
||||
<!-- Phase 50: the full-width table (AGENTS.md rule 5 — no skinny
|
||||
list): Title (the Open link → /?chat=<id>) | Messages |
|
||||
Updated | Actions (Delete, inline two-step confirm).
|
||||
Updated | Share (phase 51: Create link / Copy / Unshare —
|
||||
the row's share_url comes from GET /api/chats itself, no
|
||||
second fetch) | Actions (Delete, inline two-step confirm).
|
||||
history.js fills #history-tbody; #history-empty-row ships
|
||||
hidden and is revealed by a 0-row fetch. The Actions column
|
||||
header is visually-hidden — the row buttons carry their own
|
||||
@@ -148,12 +150,13 @@
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Messages</th>
|
||||
<th scope="col">Updated</th>
|
||||
<th scope="col">Share</th>
|
||||
<th scope="col"><span class="visually-hidden">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="history-tbody">
|
||||
<tr class="history-empty-row" id="history-empty-row" hidden>
|
||||
<td colspan="4">No saved chats yet — finish a conversation and press <strong>Save</strong> in the chat.</td>
|
||||
<td colspan="5">No saved chats yet — finish a conversation and press <strong>Save</strong> in the chat.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -134,6 +134,29 @@
|
||||
<span class="save-chat-label">Save</span>
|
||||
</button>
|
||||
|
||||
<!-- Phase 51 (owner-locked 2026-08-29, `TODO.md` L6): "Share"
|
||||
turns the current conversation into a PUBLIC read-only link —
|
||||
/shared/<token> (a 128-bit uuid4 on the saved_chats row,
|
||||
migration 0009; the anonymous page is task 03). The
|
||||
save-then-share contract: an UNSAVED (unlinked) conversation
|
||||
is saved AND shared in ONE action — app.js POSTs /api/chats
|
||||
with { messages, share: true } (the server sets the token in
|
||||
the same commit) and links the conversation to the created
|
||||
row; a saved (linked) one just POSTs /api/chats/<id>/share
|
||||
(idempotent — the existing token comes back unchanged). On
|
||||
success the ABSOLUTE link is copied to the clipboard; a
|
||||
non-secure (http) homelab origin that rejects the clipboard
|
||||
gets the inline link-field fallback instead (owner-locked —
|
||||
app.js renders .share-link-fallback near the status line).
|
||||
Admin-only — ships HIDDEN exactly like Save (absent-not-
|
||||
hidden, phase 16); app.js reveals it at boot (the same
|
||||
admin-reveal block) and binds the click to shareCurrentChat.
|
||||
Unsharing lives on the History page's Share column (task 04). -->
|
||||
<button type="button" class="share-chat-btn" id="share-chat-btn" aria-label="Share chat" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
<span class="share-chat-label">Share</span>
|
||||
</button>
|
||||
|
||||
<!-- Phase 49 (2026-08-29, TODO.md L4): the meta row under a brain
|
||||
bubble can carry JS-injected actions (app.js) — Tune (admin
|
||||
only, phase 15) and Retry (every visitor; the LAST brain
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="description" content="A shared Brain of Reese conversation — read-only.">
|
||||
<title>Shared conversation · Brain of Reese</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<header class="app-header">
|
||||
<div class="container header-inner">
|
||||
<span class="brand">
|
||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||
</span>
|
||||
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||
mobile hamburger — visible ≤640px only (CSS); opens the nav as
|
||||
an animated dropdown. Behavior: assets/header.js. -->
|
||||
<button type="button" class="nav-toggle" id="nav-toggle"
|
||||
aria-expanded="false" aria-controls="app-nav" aria-label="Menu">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||
</button>
|
||||
<nav class="app-nav" id="app-nav" aria-label="Primary">
|
||||
<!-- Phase 51 (owner-locked 2026-08-29, `TODO.md` L6): no nav link
|
||||
is "current" here — the shared page is a read-only detail
|
||||
view reachable from a link, not one of the app's pages
|
||||
(the document.html convention, phase 10/13). -->
|
||||
<a href="/" class="nav-link">Chat</a>
|
||||
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Sources link is admin-only (owner
|
||||
permission 2026-08-23) — hidden by default, header.js
|
||||
reveals it once whoami says admin. A guest on this page
|
||||
never sees it. -->
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>
|
||||
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||
link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>
|
||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Global Tuning link is admin-only (owner
|
||||
permission 2026-08-25) — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||
<!-- Phase 50 (owner permission 2026-08-29, `TODO.md` L5): the
|
||||
History link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Tuning link above. -->
|
||||
<a href="/history.html" class="nav-link" id="nav-history" hidden>History</a>
|
||||
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-in-mobile rules). -->
|
||||
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign out</span>
|
||||
</button>
|
||||
</nav>
|
||||
<!-- Phase 15: the tuning-notes panel (stored in Postgres, read
|
||||
into every system prompt) — owned by the shared header
|
||||
module (assets/header.js); the #steering-panel section
|
||||
ships in every page's <main>. The navbar toggle was
|
||||
removed at owner request (2026-08-28): note management
|
||||
lives on /tuning.html. -->
|
||||
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
||||
out is visible; /api/whoami decides at load (the shared
|
||||
header module). Icon-only below 640px (aria-labels keep the
|
||||
accessible names). -->
|
||||
<!-- Phase 51 (owner-locked 2026-08-29, `TODO.md` L6): ?next=/ —
|
||||
a guest signing in FROM a shared page returns to the APP
|
||||
ROOT, not the shared URL (the shared link stays valid and
|
||||
public either way; the app root is where a signed-in
|
||||
visitor's chat lives). header.js rewrites ?next= to the
|
||||
current pathname for an admin; the static fallback above is
|
||||
the guest's. -->
|
||||
<a href="/login.html?next=/" class="auth-link sign-in-link" id="sign-in-link" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
<button type="button" class="auth-link sign-out-btn" id="sign-out-btn" aria-label="Sign out" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main" class="app-main" tabindex="-1">
|
||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the tuning-notes panel (stored notes, newest
|
||||
first) — rendered + driven by assets/header.js (shared), not
|
||||
the page script. First child of <main> on the non-chat pages;
|
||||
the chat page keeps it after #kb-banner. -->
|
||||
<section class="steering-panel" id="steering-panel" role="region"
|
||||
aria-label="Tuning notes" hidden>
|
||||
<div class="steering-panel-head">
|
||||
<h2 class="steering-panel-title">Tuning notes</h2>
|
||||
<p class="steering-panel-sub">Every note below steers all future answers.</p>
|
||||
</div>
|
||||
<ul class="steering-list" id="steering-list"></ul>
|
||||
<p class="steering-empty" id="steering-empty">No tuning notes yet — press “Tune” under any answer to add one.</p>
|
||||
</section>
|
||||
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
|
||||
|
||||
<!-- Phase 51 (owner-locked 2026-08-29, `TODO.md` L6): the
|
||||
anonymous shared conversation — READ-ONLY, ZERO CONTROLS.
|
||||
The shell maps to the 46rem centered chat column (styles.css
|
||||
.shared-shell, the PLAN §7 column contract): the conversation
|
||||
reads exactly like the chat page's, minus the composer, the
|
||||
New chat / Save / Share pills, and every meta-row action.
|
||||
shared.js renders the records through the SAME .msg/.bubble/
|
||||
.thinking/.tool-calls structure the chat page uses, so the
|
||||
existing CSS applies unchanged. Nothing below is interactive:
|
||||
no form or button element in the content (the header's own
|
||||
controls are the shared bar's, not the conversation's), the
|
||||
"Maybe try" chips are plain <span> text (a guest tapping a
|
||||
chip has nowhere to go), and the source chips are plain text
|
||||
too (no href — guests cannot open documents, the documents
|
||||
API is admin-only, phase 16). -->
|
||||
<div class="container shared-shell">
|
||||
<h1 id="shared-title">Shared conversation</h1>
|
||||
<p class="shared-note">Shared via Brain of Reese — read-only.</p>
|
||||
|
||||
<!-- The invalid / revoked state — ship-hidden; shared.js reveals
|
||||
it for a malformed token (no fetch of any kind) and for a
|
||||
404 (wrong or revoked) / network / malformed-body read. The
|
||||
title keeps its fallback, nothing else renders, and there is
|
||||
no error banner on this page (zero controls). -->
|
||||
<div id="shared-invalid" hidden>This share link is invalid or was revoked.</div>
|
||||
|
||||
<section class="messages" id="messages" aria-label="Shared conversation">
|
||||
<!-- shared.js appends one .msg per record here. -->
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
<div class="container footer-inner">
|
||||
<span>Powered by Reese's self-hosted models</span>
|
||||
<span class="footer-version" id="app-version"></span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Phase 39: the brand layer — a CLASSIC script, first on every
|
||||
page: window.BOR_BRAND is set at parse time (before the module
|
||||
scripts evaluate) and refreshed from /api/config (a byte-
|
||||
identical no-op for the default name).
|
||||
Phase 10: the classic markdown renderer (markdown.js) —
|
||||
escape-first, XSS-safe; shared.js calls the global
|
||||
renderMarkdown on the stored raw text.
|
||||
Phase 19: the shared header module loads through the page
|
||||
script's own `import "./header.js"` — a hoisted import that is
|
||||
evaluated before the page script body calls initSharedHeader()
|
||||
at boot (no direct header.js <script> tag — single-evaluation
|
||||
design). NO modal overlay, NO composer, NO Save/Share/
|
||||
Retry/Tune markup anywhere (owner-locked: zero controls). -->
|
||||
<!-- ABSOLUTE asset paths on purpose: the page is served from the
|
||||
NESTED route /shared/<token> (not a root-level .html), so a
|
||||
relative "assets/…" ref would resolve to /shared/assets/… and
|
||||
404 (the middleware's ?v= rewrite handles both forms, but it
|
||||
cannot change the relative-ness). The static /shared.html URL
|
||||
works with absolute refs too. -->
|
||||
<script src="/assets/brand.js"></script>
|
||||
<script src="/assets/markdown.js"></script>
|
||||
<script type="module" src="/assets/shared.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user