fix(ui): document viewer back button returns to the page you came from (chat or sources)

This commit is contained in:
2026-08-22 15:26:43 -04:00
parent 8ca564cd83
commit 2485b50af0
6 changed files with 287 additions and 31 deletions
+13 -6
View File
@@ -65,14 +65,21 @@ const reducedMotion =
typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches; typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
const SCROLL = reducedMotion ? "auto" : "smooth"; const SCROLL = reducedMotion ? "auto" : "smooth";
/* ---------- document viewer link (phase 10) ---------- /* ---------- document viewer link (phase 10; phase 13 adds `back`) ----------
* Every cited document opens in the viewer, in a NEW tab. Both query * Every cited document opens in the viewer, in a NEW tab. All query
* values are percent-encoded: real paths contain slashes and sometimes * values are percent-encoded: real paths contain slashes and sometimes
* spaces, which would otherwise corrupt the query string. (The renderer * 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 * renderMarkdown/escapeHtml now lives in assets/markdown.js — a classic
* script loaded by index.html and document.html before these modules.) */ * script loaded by index.html and document.html before these modules.) */
export function documentUrl(source, path) { export function documentUrl(source, path, back = "/") {
return "/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path); let url =
"/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
if (back) url += "&back=" + encodeURIComponent(back);
return url;
} }
/* ---------- avatar glyphs (phase 08: emoji-free chrome) ---------- /* ---------- avatar glyphs (phase 08: emoji-free chrome) ----------
@@ -291,7 +298,7 @@ function appendSources(wrap, sources) {
const chip = document.createElement("a"); const chip = document.createElement("a");
chip.className = "source-chip"; chip.className = "source-chip";
chip.setAttribute("role", "listitem"); chip.setAttribute("role", "listitem");
chip.href = documentUrl(s.source, s.path); chip.href = documentUrl(s.source, s.path, "/"); // back → the chat page
chip.target = "_blank"; // open the full document in a new tab chip.target = "_blank"; // open the full document in a new tab
chip.rel = "noopener"; chip.rel = "noopener";
chip.textContent = label; chip.textContent = label;
+25 -8
View File
@@ -28,14 +28,31 @@ const notFoundEl = document.querySelector("#doc-not-found");
const mainEl = document.querySelector("#main"); const mainEl = document.querySelector("#main");
const backLink = document.querySelector("#doc-back"); const backLink = document.querySelector("#doc-back");
/* Back: prefer the browser's own history when there is one (the viewer was /* Back button (phase 13): the return target comes from the `back` query
* opened from this tab's session); a fresh tab lands on the Sources page. */ * param, not the browser history — both entry points (chat source chips
backLink.addEventListener("click", (e) => { * and the Sources table) open the viewer in a NEW tab, where there is no
if (window.history.length > 1) { * history to go back to. The param is honored only for same-origin
e.preventDefault(); * relative URLs (starts with "/" but not "//"), so absolute (https://…),
window.history.back(); * protocol-relative (//…), and pseudo-protocol (javascript:…) values are
} * rejected; anything else falls back to the Sources page. The static
}); * href="/sources.html" in document.html remains the no-JS fallback, and
* with the href set the anchor's default click behavior IS the
* deterministic navigation (no browser-history heuristics). */
const backParam = params.get("back") || "";
const backTarget =
backParam.startsWith("/") && !backParam.startsWith("//")
? backParam
: "/sources.html";
backLink.href = backTarget;
const backLabel = backLink.querySelector("span");
if (backLabel) {
backLabel.textContent =
backTarget === "/"
? "Chat"
: backTarget === "/sources.html"
? "Sources"
: "Back";
}
function fmtDate(iso) { function fmtDate(iso) {
try { try {
+3 -2
View File
@@ -98,12 +98,13 @@ def test_on_topic_question_streams_grounded_answer(
# Grounded: a kubernetes.md source chip renders under the bubble # Grounded: a kubernetes.md source chip renders under the bubble
# (top-N docs can add more chips; the question's doc must be among them). # (top-N docs can add more chips; the question's doc must be among them).
# Phase 10: chips open the document viewer in a new tab (encoded URL). # Phase 10: chips open the document viewer in a new tab (encoded URL);
# phase 13 appends back=/ so the viewer's back button returns to chat.
chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md") chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
expect(chip).to_have_count(1) expect(chip).to_have_count(1)
expect(chip.first).to_contain_text("kubernetes.md") expect(chip.first).to_contain_text("kubernetes.md")
expect(chip.first).to_have_attribute( expect(chip.first).to_have_attribute(
"href", "/document.html?source=docs&path=homelab%2Fkubernetes.md" "href", "/document.html?source=docs&path=homelab%2Fkubernetes.md&back=%2F"
) )
expect(chip.first).to_have_attribute("target", "_blank") expect(chip.first).to_have_attribute("target", "_blank")
expect(chip.first).to_have_attribute("rel", "noopener") expect(chip.first).to_have_attribute("rel", "noopener")
+206
View File
@@ -0,0 +1,206 @@
"""Phase 13 E2E (Playwright): the viewer's back button returns to the
page the document was opened from.
Story: ``.agent/user_stories/document-back-navigation.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_document_back_navigation.py -v --no-cov
Both entry points (chat source chips, Sources table links) open the viewer
in a NEW tab, where there is no browser history — so the return target is
carried in the viewer URL: chat chips append ``&back=%2F`` (resolves to
"Chat"), Sources links omit the param (the viewer's default
``/sources.html`` applies → "Sources"). The viewer only honors
same-origin relative ``back`` values; everything else falls back to
``/sources.html``.
Test → story mapping (Playwright Mapping Rule):
1. ``test_back_from_chat_returns_to_chat`` — question → source chip →
new tab with ``&back=%2F`` → back link href ``/`` labeled "Chat" →
click → the chat page.
2. ``test_back_from_sources_returns_to_sources`` — Sources table link →
new tab without a ``back`` param → back link href ``/sources.html``
labeled "Sources" → click → the Sources page.
3. ``test_malicious_back_param_is_rejected`` — absolute,
protocol-relative, and ``javascript:`` ``back`` values all fall back
to ``/sources.html`` (labeled "Sources", navigable).
"""
from __future__ import annotations
import asyncio
import re
from pathlib import Path
from threading import Thread
from typing import Any
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
QUESTION = "How is my Kubernetes cluster set up?"
# Seeded fixture doc (source=docs) shared by every test in this file.
DOC_SOURCE = "docs"
DOC_PATH = "homelab%2Fkubernetes.md"
DOC_TITLE = "Kubernetes Homelab Cluster"
async def _import_fixtures(mock_port: int) -> ImportSummary:
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread.
Playwright's sync API keeps an asyncio loop running on the test thread,
so ``asyncio.run`` cannot be called directly from a test body.
"""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
"""Truncate the KB (and query log), then optionally re-import fixtures."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit()
if not seed:
return None
return _run_in_thread(_import_fixtures(mock_port))
# ---------------------------------------------------------------------------
# 1. Chat source chip → viewer with back=/ → back returns to the chat
# ---------------------------------------------------------------------------
def test_back_from_chat_returns_to_chat(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
page.fill("#message-input", QUESTION)
page.click("#send-btn")
chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
expect(chip).to_have_count(1, timeout=30_000)
# Chat chips carry back=/ (encoded %2F) so the viewer knows where home is.
expect(chip.first).to_have_attribute(
"href", f"/document.html?source={DOC_SOURCE}&path={DOC_PATH}&back=%2F"
)
with page.expect_popup() as popup_info:
chip.first.click()
viewer = popup_info.value
expect(viewer).to_have_url(
re.compile(
re.escape(f"{app_url}/document.html?source={DOC_SOURCE}&path={DOC_PATH}&back=%2F")
)
)
# The cited document actually rendered (this is the viewer, not an error).
expect(viewer.locator("#doc-title")).to_have_text(DOC_TITLE)
# Back link resolved to the chat page, labeled "Chat".
back = viewer.locator("#doc-back")
expect(back).to_have_attribute("href", "/")
expect(back).to_have_text("Chat")
# Click: deterministic anchor navigation back to the chat page.
back.click()
expect(viewer).to_have_url(f"{app_url}/")
expect(viewer.locator("#composer")).to_be_visible()
# ---------------------------------------------------------------------------
# 2. Sources table link → viewer without back param → back returns to Sources
# ---------------------------------------------------------------------------
def test_back_from_sources_returns_to_sources(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.goto(f"{app_url}/sources.html")
row = page.locator("#docs-tbody tr", has_text="kubernetes.md")
expect(row).to_have_count(1)
link = row.locator("td:nth-child(2) a.doc-link")
expect(link).to_have_count(1)
# Sources links carry NO back param — the viewer's default target
# (/sources.html) applies.
expect(link).to_have_attribute(
"href", f"/document.html?source={DOC_SOURCE}&path={DOC_PATH}"
)
with page.expect_popup() as popup_info:
link.click()
viewer = popup_info.value
assert "back=" not in viewer.url, f"unexpected back param: {viewer.url}"
expect(viewer.locator("#doc-title")).to_have_text(DOC_TITLE)
# Back link kept the default target, labeled "Sources".
back = viewer.locator("#doc-back")
expect(back).to_have_attribute("href", "/sources.html")
expect(back).to_have_text("Sources")
back.click()
expect(viewer).to_have_url(f"{app_url}/sources.html")
expect(viewer.locator("#docs-table")).to_be_visible()
# ---------------------------------------------------------------------------
# 3. Hostile back values (absolute, protocol-relative, pseudo-protocol)
# are all rejected in favor of the same-origin default
# ---------------------------------------------------------------------------
def test_malicious_back_param_is_rejected(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
errors: list[str] = []
dialogs: list[str] = []
page.on("pageerror", lambda e: errors.append(str(e)))
def _catch_dialog(d) -> None: # a fired dialog == executed script
dialogs.append(d.message)
d.dismiss()
page.on("dialog", _catch_dialog)
viewer_base = f"{app_url}/document.html?source={DOC_SOURCE}&path={DOC_PATH}"
# Anything that is not a same-origin relative URL must be rejected:
# an absolute https URL, a protocol-relative URL, and a javascript:
# pseudo-protocol.
for evil in ("https%3A%2F%2Fevil.com", "%2F%2Fevil.com", "javascript%3Aalert(1)"):
page.goto(f"{viewer_base}&back={evil}")
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE)
back = page.locator("#doc-back")
expect(back).to_have_attribute("href", "/sources.html")
expect(back).to_have_text("Sources")
# And the fallback is really navigable: clicking lands on Sources.
page.click("#doc-back")
expect(page).to_have_url(f"{app_url}/sources.html")
assert dialogs == [], f"dialog fired — a back param escaped validation: {dialogs}"
assert errors == [], f"console crashes: {errors}"
+7 -4
View File
@@ -99,10 +99,11 @@ def test_source_chip_opens_document(
chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md") chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
expect(chip).to_have_count(1, timeout=30_000) expect(chip).to_have_count(1, timeout=30_000)
# New-tab contract: same-origin viewer URL, both query values encoded # New-tab contract: same-origin viewer URL, all query values encoded
# (the path's slashes come out as %2F — exactly why encoding matters). # (the path's slashes come out as %2F — exactly why encoding matters),
# plus back=/ (phase 13) so the viewer's back button returns to chat.
expect(chip.first).to_have_attribute( expect(chip.first).to_have_attribute(
"href", "/document.html?source=docs&path=homelab%2Fkubernetes.md" "href", "/document.html?source=docs&path=homelab%2Fkubernetes.md&back=%2F"
) )
expect(chip.first).to_have_attribute("target", "_blank") expect(chip.first).to_have_attribute("target", "_blank")
expect(chip.first).to_have_attribute("rel", "noopener") expect(chip.first).to_have_attribute("rel", "noopener")
@@ -112,7 +113,9 @@ def test_source_chip_opens_document(
viewer = popup_info.value viewer = popup_info.value
expect(viewer).to_have_url( expect(viewer).to_have_url(
re.compile( re.compile(
re.escape(f"{app_url}/document.html?source=docs&path=homelab%2Fkubernetes.md") re.escape(
f"{app_url}/document.html?source=docs&path=homelab%2Fkubernetes.md&back=%2F"
)
) )
) )
expect(viewer.locator("#doc-title")).to_have_text("Kubernetes Homelab Cluster") expect(viewer.locator("#doc-title")).to_have_text("Kubernetes Homelab Cluster")
+33 -11
View File
@@ -160,18 +160,26 @@ def test_content_requires_both_params() -> None:
def test_viewer_url_builder_present_in_chat_and_sources() -> None: def test_viewer_url_builder_present_in_chat_and_sources() -> None:
"""Both entry points (chat chips, Sources rows) build the same """Both entry points (chat chips, Sources rows) build the same
encoded viewer URL and open it in a new tab with rel=noopener.""" encoded viewer URL and open it in a new tab with rel=noopener.
Phase 13: the chat builder additionally carries ``back=/`` (encoded
%2F) so the viewer's back button returns to the chat; Sources links
intentionally omit the param (the viewer's /sources.html default)."""
for name, js in (("app.js", _read(APP_JS)), ("sources.js", _read(SOURCES_JS))): for name, js in (("app.js", _read(APP_JS)), ("sources.js", _read(SOURCES_JS))):
assert "function documentUrl(source, path)" in js, name
assert '"/document.html?source=" + encodeURIComponent(' in js, name assert '"/document.html?source=" + encodeURIComponent(' in js, name
assert '"&path=" + encodeURIComponent(' in js, name assert '"&path=" + encodeURIComponent(' in js, name
app_js = _read(APP_JS) app_js = _read(APP_JS)
assert "chip.href = documentUrl(s.source, s.path)" in app_js # Chat: 3-arg builder with back defaulting to the chat page.
assert 'function documentUrl(source, path, back = "/")' in app_js
assert '"&back=" + encodeURIComponent(back)' in app_js
assert 'chip.href = documentUrl(s.source, s.path, "/")' in app_js
assert 'chip.target = "_blank"' in app_js assert 'chip.target = "_blank"' in app_js
assert 'chip.rel = "noopener"' in app_js assert 'chip.rel = "noopener"' in app_js
sources_js = _read(SOURCES_JS) sources_js = _read(SOURCES_JS)
# Sources: unchanged 2-arg builder — no back param in the URL.
assert "function documentUrl(source, path)" in sources_js
assert 'link.className = "doc-link"' in sources_js assert 'link.className = "doc-link"' in sources_js
assert "link.href = documentUrl(d.source, d.path)" in sources_js assert "link.href = documentUrl(d.source, d.path)" in sources_js
assert 'link.target = "_blank"' in sources_js assert 'link.target = "_blank"' in sources_js
@@ -188,7 +196,9 @@ def _run_node(script: str) -> str:
def _extract_function(js: str, name: str) -> str: def _extract_function(js: str, name: str) -> str:
match = re.search(rf"(?:export )?function {name}\(source, path\) \{{.*?\n\}}", js, re.S) match = re.search(
rf"(?:export )?function {name}\(source, path(?:, back = \"/\")?\) \{{.*?\n\}}", js, re.S
)
assert match, f"{name}(source, path) not found" assert match, f"{name}(source, path) not found"
return match.group(0).replace("export ", "", 1) return match.group(0).replace("export ", "", 1)
@@ -196,18 +206,21 @@ def _extract_function(js: str, name: str) -> str:
@pytest.mark.skipif(not HAVE_NODE, reason="node not available") @pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_viewer_url_builder_encodes_spaces_and_slashes() -> None: def test_viewer_url_builder_encodes_spaces_and_slashes() -> None:
"""Behavioral check of the real builder (app.js) under node: slashes """Behavioral check of the real builder (app.js) under node: slashes
and spaces in source/path values must come out percent-encoded.""" and spaces in source/path values must come out percent-encoded, and
the back target is appended + encoded (phase 13)."""
fn = _extract_function(_read(APP_JS), "documentUrl") fn = _extract_function(_read(APP_JS), "documentUrl")
out = _run_node( out = _run_node(
f"{fn}\n" f"{fn}\n"
"console.log(documentUrl('Homelab', 'kubernetes.md'));\n" "console.log(documentUrl('Homelab', 'kubernetes.md'));\n"
"console.log(documentUrl('Homelab', 'notes/my file.yaml'));\n" "console.log(documentUrl('Homelab', 'notes/my file.yaml'));\n"
"console.log(documentUrl('H omelab', 'a/b.md'));" "console.log(documentUrl('H omelab', 'a/b.md'));\n"
"console.log(documentUrl('Homelab', 'kubernetes.md', '/sources.html'));"
) )
assert out.splitlines() == [ assert out.splitlines() == [
"/document.html?source=Homelab&path=kubernetes.md", "/document.html?source=Homelab&path=kubernetes.md&back=%2F",
"/document.html?source=Homelab&path=notes%2Fmy%20file.yaml", "/document.html?source=Homelab&path=notes%2Fmy%20file.yaml&back=%2F",
"/document.html?source=H%20omelab&path=a%2Fb.md", "/document.html?source=H%20omelab&path=a%2Fb.md&back=%2F",
"/document.html?source=Homelab&path=kubernetes.md&back=%2Fsources.html",
] ]
@@ -259,10 +272,19 @@ def test_markdown_renderer_stays_xss_safe_and_unchanged() -> None:
def test_viewer_js_rendering_contracts() -> None: def test_viewer_js_rendering_contracts() -> None:
"""document.js: raw formats go in via textContent (never parsed as """document.js: raw formats go in via textContent (never parsed as
HTML), markdown via the shared renderer, 404 → designed not-found HTML), markdown via the shared renderer, 404 → designed not-found
state, back link prefers browser history when there is one.""" state, and (phase 13) the back link resolves the ``back`` param —
same-origin relative URLs only, /sources.html default, no browser
history heuristics (both entry points are fresh tabs)."""
js = _read(DOCUMENT_JS) js = _read(DOCUMENT_JS)
assert "pre.textContent = doc.content" in js # raw formats: text node assert "pre.textContent = doc.content" in js # raw formats: text node
assert "renderMarkdown(doc.content)" in js # md/markdown: shared renderer assert "renderMarkdown(doc.content)" in js # md/markdown: shared renderer
assert "showNotFound" in js assert "showNotFound" in js
assert "history.length > 1" in js # Phase 13: deterministic back-target resolution, no history heuristics.
assert "history.length" not in js
assert "window.history.back" not in js
assert 'backParam.startsWith("/")' in js # same-origin relative only…
assert 'backParam.startsWith("//")' in js # …and not protocol-relative
assert 'backLink.href = backTarget' in js # deterministic anchor navigation
assert '"/sources.html"' in js # default target + no-JS fallback value
assert '"Chat"' in js and '"Sources"' in js # labels for the two entry points
assert "encodeURIComponent" in js # content fetch uses the same encoding assert "encodeURIComponent" in js # content fetch uses the same encoding