feat(ui): documents open in an almost-fullscreen modal instead of a new page — same-page overlay on chat + Sources, /document.html kept as the no-JS/direct-link fallback

This commit is contained in:
2026-08-25 13:45:57 -04:00
parent 476aa0e066
commit fcde1fd37b
18 changed files with 1307 additions and 258 deletions
+68 -26
View File
@@ -60,10 +60,20 @@
* (thinking or answer). scrollReveal(wrap) is the single scroll gate;
* `force` is reserved for the one-shot phase-14 restore landing.
*
* Document modal (phase 26): a source chip opens the cited document in
* the almost-fullscreen modal overlay (assets/document-modal.js) on the
* SAME page — no new tab, no navigation. The chip keeps its
* /document.html href as the no-JS / context-menu escape hatch;
* left-clicks are intercepted (preventDefault) and routed to
* openDocumentModal. The module is loaded through the relative import
* below — the header.js single-evaluation design (no direct <script>
* tag; esbuild inlines it into the page bundle).
*
* All DOM ids match frontend/index.html.
*/
import { fetchIsAdmin, initSharedHeader } from "./header.js";
import { openDocumentModal } from "./document-modal.js"; // phase 26: chips open the same-page modal
const messagesEl = document.querySelector("#messages");
const emptyState = document.querySelector("#empty-state");
@@ -134,15 +144,19 @@ function scrollReveal(wrap, behavior = SCROLL, force = false) {
}
/* ---------- document viewer link (phase 10; phase 13 adds `back`) ----------
* Every cited document opens in the viewer, in a NEW tab. All query
* values are percent-encoded: real paths contain slashes and sometimes
* spaces, which would otherwise corrupt the query string. `back` tells the
* viewer which page to return to when its back button is clicked — the
* chips live in the chat, so chat passes "/" (the viewer validates it:
* only same-origin relative URLs are honored; Sources links omit it and
* get the viewer's /sources.html default). (The renderer
* renderMarkdown/escapeHtml now lives in assets/markdown.js — a classic
* script loaded by index.html and document.html before these modules.) */
* The href a source chip carries: the dedicated viewer (no-JS /
* context-menu escape hatch). Phase 26: the chip's left-click is
* intercepted and the document opens in the same-page modal instead
* (document-modal.js) — this URL is also what the modal's "Full page"
* link points at. All query values are percent-encoded: real paths
* contain slashes and sometimes spaces, which would otherwise corrupt
* the query string. `back` tells the viewer which page to return to
* when its back button is clicked — the chips live in the chat, so chat
* passes "/" (the viewer validates it: only same-origin relative URLs
* are honored; Sources links omit it and get the viewer's /sources.html
* default). (The renderer renderMarkdown/escapeHtml now lives in
* assets/markdown.js — a classic script loaded by index.html and
* document.html before these modules.) */
export function documentUrl(source, path, back = "/") {
let url =
"/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
@@ -569,28 +583,53 @@ function autoGrow() {
/* ---------- chat turn (SSE streaming, PLAN §4) ---------- */
/* Cancel a response body without leaking an unhandled rejection:
* while readSSE's reader is still attached, cancel() on a LOCKED stream
* REJECTS (a rejected promise — a try/catch around the call cannot see
* it), which surfaced as a "Cannot cancel a locked stream" page error on
* every completed turn. Both outcomes are fine here: the stream is dead
* or dying. */
function cancelStream(res) {
try {
res?.body?.cancel().catch(() => {});
} catch {
/* body already consumed/closed */
}
}
/* Parse an SSE response body into JSON events. */
async function readSSE(response, onEvent) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buf = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let sep;
while ((sep = buf.indexOf("\n\n")) !== -1) {
const frame = buf.slice(0, sep).trim();
buf = buf.slice(sep + 2);
if (!frame.startsWith("data:")) continue;
const payload = frame.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
onEvent(JSON.parse(payload));
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let sep;
while ((sep = buf.indexOf("\n\n")) !== -1) {
const frame = buf.slice(0, sep).trim();
buf = buf.slice(sep + 2);
if (!frame.startsWith("data:")) continue;
const payload = frame.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
onEvent(JSON.parse(payload));
}
}
} finally {
// Release the reader's lock: with it held, the turn-end
// cancelStream(res) below rejects (locked stream — unhandled
// promise rejection). Released, the finished stream is closed and
// cancel() settles quietly.
reader.releaseLock();
}
}
/* Source chips (mono, source/path) under a Brain bubble. */
/* Source chips (mono, source/path) under a Brain bubble. Phase 26:
* clicking a chip opens the document in the same-page modal (no new
* tab) — the /document.html href stays as the no-JS / context-menu
* escape hatch. */
function appendSources(wrap, sources) {
if (!sources || !sources.length) return;
const body = wrap.querySelector(".msg-body");
@@ -604,8 +643,11 @@ function appendSources(wrap, sources) {
chip.className = "source-chip";
chip.setAttribute("role", "listitem");
chip.href = documentUrl(s.source, s.path, "/"); // back → the chat page
chip.target = "_blank"; // open the full document in a new tab
chip.rel = "noopener";
chip.addEventListener("click", (e) => {
e.preventDefault(); // no new tab (phase 26) — the modal takes over
e.stopPropagation();
openDocumentModal(s.source, s.path, chip);
});
chip.textContent = label;
chip.title = label;
meta.appendChild(chip);
@@ -862,7 +904,7 @@ async function handleSend(e) {
setUiState(UI_STATE.thinking);
armTurnTimeout(() => {
aborted = true;
try { res?.body?.cancel(); } catch { /* already closed */ }
cancelStream(res); // best-effort: the reader may still hold the lock
setUiState(UI_STATE.error, "That's taking a long time — the answer may be stuck.");
});
@@ -968,7 +1010,7 @@ async function handleSend(e) {
// turn-local, so a page reload mid-stream leaves a usable composer.
clearTurnTimeout();
stopThinkingClock();
try { res?.body?.cancel(); } catch { /* stream already closed */ }
cancelStream(res); // the reader lock is released — no unhandled rejection
if (uiState !== UI_STATE.idle) setUiState(UI_STATE.idle);
// Phase 18: focus back for the next question, but never move the
// viewport — a user reading earlier content stays where they are.