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:
+68
-26
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user