phase: 144_modal_back_button
Build and Push Containers / build-and-push-app (push) Successful in 2m21s
Build and Push Containers / build-and-push-db (push) Successful in 17s

**Phase 144 final verification pass — complete, 100% green.**

- **Verified:** all 3 tasks already implemented in-tree — `document-modal.js` history-aware modal (one state-only entry per open: push on open, adopt on forward re-open, in-place `replaceState(prev)` on button/Escape/backdrop close, `closingViaBack`-guarded back-close, top-level popstate listener), `tests/unit/test_frontend_doc_modal.py` source pins, `tests/e2e/test_modal_back_button.py` (4 tests). No code fixes needed.
- **Tests:** `uv run pytest` → 3371 passed · `uv run pytest --cov=app --cov-report=term-missing` → 3371 passed, **app/ 99%** (gate >90%) · `uv run pytest tests/e2e/test_modal_back_button.py -v --no-cov` → 4 passed in isolation · regressions in isolation: `test_document_back_navigation.py` 3 passed, `test_kb_tree_nav.py` 4 passed · `uv run ruff check .` clean, `uv run pyright` 0 errors.
- **Completion criteria:** back closes modal (URL/view/drill untouched; second back navigates as usual) ✅ · back→forward re-opens same doc, no duplicate entry ✅ · button/Escape/backdrop close leaves no dead back step ✅ (state byte-identity + modal-free back chain; `replaceState` can't shrink `history.length` — documented deviation, locked design forbids `history.back()/go()`) · all gates green ✅ · no behavior change in completed phases (pre-phase pins + both regression E2Es green) ✅.
- **Docs:** README — one feature-description gap fixed (added the phase-144 Back/Forward modal behavior to the Chat section); all other README facts verified against code (routes, every `.env.example` var, all ~40 documented defaults vs `app/config.py`). AGENTS.md — verified consistent, no changes needed.
- **Decisions:** no live-infrastructure work touched; all changes left uncommitted in the working tree per harness protocol.
- **Next pending phase:** `145_git_source_links`.
This commit is contained in:
2026-10-01 08:49:50 -04:00
parent ba4841a811
commit 486ceb4019
21 changed files with 1431 additions and 1 deletions
@@ -0,0 +1,38 @@
# Phase 144 — the browser back button closes the document modal, not the view behind it
**Source:** `TODO.md` L6 — "back button doesn't close the modal of you're looking at but instead navigates back behind the modal which can lead to confusion"
**Story:** n/a (TODO-derived phase, Protocol B append).
**Context:** `frontend/assets/document-modal.js` — the single owner of `#doc-modal` (the near-fullscreen document viewer; `openDocumentModal` L141+ — trigger memory, the Escape/Tab capture, the `fetchSeq` stale-guard; `closeDocumentModal` — hide, drop the capture, restore focus to the trigger; the close affordances: `#doc-modal-close`, backdrop click, Escape); `frontend/assets/router.js` — the shell's popstate (L283–286: reads the PATHNAME only — a state-only entry's pop is a no-op for it); `frontend/assets/sources.js` — the existing STATE-ONLY-entry convention (L237–260, L1539: `history.pushState({ view: "rag", kb: target }, "")` — the second arg `""` keeps the current URL) and its popstate adopt/reset (L1949–1954: adopts the popped entry's `kb` or resets the drill — a pop onto the SAME state the view already shows is invisible); `tests/unit/test_remove_confirm_modal.py` (the source-level modal-pin precedent); the phase-99 D2 "the browser owns its entries" convention.
**Locked (owner-confirmed 2026-09-30, chat):** scope = the document modal (`#doc-modal`) only — the Sources-page admin confirm dialogs (the `remove-confirm` / `ignore-editor` `<dialog>` elements) are quick confirmations, not "modals you look at", and stay as-is. Back closes the modal via a state-only history entry; forward re-opens the modal (the browser owns its entries).
## Objective
On mobile and desktop alike, the browser Back button while the document modal is open CLOSES the modal and leaves the user exactly where they were (same view, same URL, same Sources drill) — instead of navigating the view behind the still-open modal. A Back-then-Forward re-opens the same document; a button/Escape/backdrop close leaves the history exactly as if the modal had never been opened.
## Dependencies
- `143_mobile_keyboard_after_answer` (todo) — pipeline order only; no code dependency.
## Design (shared by all tasks — the executor reads this, not the chat)
The modal becomes history-aware with ONE state-only entry per open — the `sources.js` convention, keyed on a `modal` state key the router (pathname-only) and the sources drill (`kb` key) both ignore:
- **Open** (`openDocumentModal`, after the skeleton guard — the `window.open` fallback path pushes NOTHING): remember the entry we are sitting on (`prev = history.state`), then — IF AND ONLY IF the current entry is not already a modal entry — `history.pushState({ modal: "doc", source, path }, "")` (state-only: the URL does not move). The "not already a modal entry" rule has exactly two callers: the normal open (sitting on the view's entry → push) and the forward re-open below (sitting on the modal's own entry → adopt, no push). If the modal is ALREADY open (defensive — the overlay makes it unreachable by pointer, but the programmatic contract must not stack entries), `replaceState` the existing modal entry in place (the remembered `prev` is kept) instead of pushing.
- **Back** (the popstate handler, armed once at module top level like the other bindings): `popstate` fires with the state of the entry we LANDED ON. The modal is open and the landed state is NOT a `modal` entry → the browser moved off the modal's entry → that is the back press: remember `lastPrev = the landed state` (the entry below the modal's — needed for the forward re-open's restore), close the modal (a `closingViaBack` flag around the call so the close does not also `replaceState`). The router's popstate is a no-op (same pathname → `name === current`); the sources drill re-adopts the SAME state it was already showing (its `kb` key is the state we sat on before the push — invisible). Net: the modal closes, nothing else moves.
- **Forward onto the modal's entry** (modal closed, `event.state.modal === "doc"`): the browser owns its entries (phase-99 D2) — re-open the document the entry names: `openDocumentModal(st.source, st.path, null)` (trigger null — focus goes to the content target, the normal open path). The open sees the current entry IS a modal entry → ADOPTS it instead of pushing: `entry = { prev: lastPrev, source, path }` (`lastPrev` — the below-entry remembered at the back-close; it is always available: a modal entry can only be in the forward stack because a back-close popped it first).
- **Button / Escape / backdrop close** (not a back press): the modal entry is still the current entry — remove it IN PLACE with `history.replaceState(entry.prev, "")` (no popstate fires, no other listener runs, the view state is restored byte-for-byte) — so the NEXT back behaves exactly as if the modal had never been opened. Guard: run the `replaceState` only when `history.state` is still a modal entry (the invariant — the three affordances are reachable only while open, and open means sitting on the modal entry; the check is the fail-safe if the invariant ever breaks: do nothing to the history rather than corrupt the current entry).
- Module state: `let entry = null` — `{ prev, source, path }` while the modal is open (the `prev` to restore, the doc the entry names); `let lastPrev = null` — the below-entry remembered at each back-close (the adopt's `prev`); both `null`/stale otherwise. The popstate handler and the open/close paths are the ONLY writers. Known edge (documented, accepted): if the user opens the modal, backs out, switches views, then FORWARDS onto the modal entry, the modal re-opens over the new view — the browser owns its entries (the sources drill does the same with its `kb` entries); the modal is always one close-away.
## Tasks
1. `01_history_aware_modal.md` — the push/replace/popstate mechanics in `document-modal.js` (open, back, forward, the three close affordances).
2. `02_unit_pins.md` — the source-level unit pins (the `test_remove_confirm_modal.py` pattern) for the entry lifecycle and the listener interplay.
3. `03_e2e_back_closes_modal.md` — the dedicated E2E: back closes the modal (URL + view + drill untouched), button-close keeps history sane, forward re-opens.
## Testing & Quality
- Unit: the source-level pins (the push on open, the `closingViaBack`-guarded close, the `replaceState(prev)` on non-back close, the popstate handler's two branches, the re-open suppression).
- E2E: one dedicated Playwright file, run in isolation, mock-only — `page.goBack()` / `page.goForward()` drive the real history.
- Regression: `tests/e2e/test_document_back_navigation.py` (the phase-13 /document.html back-nav story — the FULL-PAGE viewer, a different page: it must stay green) and the phase-99 Sources drill E2E (the kb back/forward story — the interplay must not leak).
- Coverage: **>90%** on new/modified code (frontend — the `app/` gate stays green).
## Completion Criteria
- [ ] with the modal open: Back → the modal closes, the URL is unchanged, the view is unchanged, the Sources drill (when opened from Sources) is unchanged; a SECOND Back then leaves the view as usual
- [ ] Back → Forward → the same document re-opens in the modal (no duplicate history entry)
- [ ] button / Escape / backdrop close → the next Back behaves exactly as if the modal had never been opened (no dead no-op back step)
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run pytest tests/e2e/test_modal_back_button.py -v --no-cov` green in isolation; the two regression E2Es still green in isolation; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the router's popstate, the sources drill's adopt/reset, the modal's focus trap / Escape / fetch-seq contracts, the admin confirm dialogs)
@@ -0,0 +1,93 @@
# Task 01 — the history-aware modal: push, adopt, back-close, in-place removal
**Phase:** `144_modal_back_button` · **Source:** `TODO.md:6` — "back button doesn't close the modal of you're looking at but instead navigates back behind the modal which can lead to confusion"
**Story:** n/a
## Objective
`#doc-modal` owns one state-only history entry per open: Back closes the modal (view/URL/drill untouched), a non-back close removes the entry in place (the next Back behaves as if the modal never opened), and Forward onto the modal's entry re-opens the same document.
## Work
1. `frontend/assets/document-modal.js` — module state beside the existing `triggerEl` / `fetchSeq` lets: `let entry = null;` (`{ prev, source, path }` while the modal is open — `prev` is the below-entry to restore, `source`/`path` are the doc the entry names) and `let lastPrev = null;` (the below-entry remembered at each back-close — the forward re-open's restore source). Comment each on the `fetchSeq` house style (what it is, who writes it).
2. `frontend/assets/document-modal.js` — `openDocumentModal` (L141+), after the skeleton guard (the `window.open` fallback stays BEFORE this — it pushes nothing) and before `modalEl.hidden = false`:
```js
/* Phase 144 (TODO L6): the modal owns ONE state-only history entry —
* Back closes the modal instead of navigating the view behind it.
* The entry is state-only (second arg "" — the URL does not move),
* so the router's pathname-only popstate is a no-op and the Sources
* drill re-adopts the state it was already showing (the sources.js
* state-only-entry convention, L1539). The current entry is a modal
* entry only on the forward re-open (the popstate handler below
* calls us sitting on it) — then we ADOPT it; otherwise we push. */
const st = history.state;
if (entry) {
// Defensive re-open while open: replace our entry in place (the
// remembered prev is kept) — never stack a second entry.
entry.source = source;
entry.path = path;
history.replaceState({ modal: "doc", source, path }, "");
} else if (st && st.modal === "doc") {
// Forward onto our own entry: adopt — the browser already sits on
// it (lastPrev is the below-entry remembered at the back-close).
entry = { prev: lastPrev, source, path };
} else {
entry = { prev: st, source, path };
history.pushState({ modal: "doc", source, path }, "");
}
```
(The rest of the open body — trigger memory, `showLoading`, the Escape/Tab capture, the fetch — is untouched.)
3. `frontend/assets/document-modal.js` — `closeDocumentModal` (L~200): at the top (after the existing `if (!modalEl || modalEl.hidden) return;`), before the hide, add the entry removal:
```js
/* Phase 144: a NON-back close removes the modal's entry IN PLACE —
* no popstate fires, no other listener runs, the view state is back
* byte-for-byte, so the next Back behaves as if the modal never
* opened. A back close (closingViaBack — the popstate handler) has
* already been moved off the entry by the browser: no replace. */
if (entry && !closingViaBack && history.state && history.state.modal === "doc") {
history.replaceState(entry.prev, "");
}
entry = null;
```
with `let closingViaBack = false;` beside the other lets (reset by the popstate handler after each close call — it is set ONLY around that call).
4. `frontend/assets/document-modal.js` — the popstate listener, armed ONCE at module top level (next to the existing `closeEl`/`backdropEl` bindings — a missing skeleton means the listener is a no-op via the `entry`/`modalEl` guards, the header.js pattern):
```js
/* Phase 144 (TODO L6): back/forward onto the modal's entry. popstate
* fires with the state of the entry we LANDED on. Back: the modal is
* open and we landed on a non-modal state — the browser moved off our
* entry; close the modal (remember the landed state as lastPrev for
* the forward re-open; closingViaBack keeps the close from also
* replaceState-ing). Forward: the modal is closed and we landed on a
* modal entry — the browser owns its entries (the phase-99 D2
* convention) — re-open the doc the entry names (the open adopts the
* entry — no second push). Every other popstate (the router's view
* entries, the drill's kb entries) passes through untouched. */
window.addEventListener("popstate", (event) => {
const st = event.state;
const isModalEntry = st && st.modal === "doc";
if (isModalEntry && modalEl && modalEl.hidden) {
openDocumentModal(st.source, st.path, null);
return;
}
if (!isModalEntry && modalEl && !modalEl.hidden && entry) {
lastPrev = st;
closingViaBack = true;
closeDocumentModal();
closingViaBack = false;
}
});
```
5. Update the module's header docstring contract block (the `openDocumentModal` / `closeDocumentModal` bullets, L12–20): one bullet for the history behavior (the entry, Back closes, the in-place removal, the forward re-open) — the file's self-documentation convention.
- ASSUMPTION: the state key is `modal: "doc"` (a string discriminator, not a unique token) — a second modal type would use its own key (the `kb` / `view` key convention); the value pair (`source`, `path`) rides in the state so the forward re-open names its document (percent-encoding is irrelevant — the state is structured, not a URL).
- ASSUMPTION: `lastPrev` (not `history.state`) is the adopt's `prev` — at a forward re-open the browser sits ON the modal entry, so the below-entry is only the one remembered at the back-close; a modal entry can reach the forward stack only through a back-close, so it is always available (the null case — e.g. a forward after a hard page reload into a modal URL — is impossible: modal entries are same-document pushes, never URL-deep-links).
## Testing & Quality
- Unit: task 02's source-level pins (this task ships with them green — the pins are written against the final shape above).
- Coverage: **>90%** on new/modified code (frontend — the `app/` gate stays green).
## Completion Criteria
- [ ] open → one state-only entry (URL unchanged); re-open-while-open → in-place replace, still one entry
- [ ] Back with the modal open → the modal closes, `lastPrev` remembered, no `replaceState` (the browser owns the pop)
- [ ] button/Escape/backdrop close → `replaceState(entry.prev)` runs (the entry is gone in place); the next Back is a real navigation step, not a dead no-op
- [ ] Forward onto the modal's entry → the same document re-opens, no second push
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed work (the router's popstate, the sources drill, the modal's focus trap / Escape / fetch-seq / trigger-focus contracts — the new state sits beside them)
@@ -0,0 +1,27 @@
# Task 02 — the source-level pins: the entry lifecycle, the two popstate branches, the no-leak guarantees
**Phase:** `144_modal_back_button` · **Source:** `TODO.md:6` — "back button doesn't close the modal of you're looking at but instead navigates back behind the modal which can lead to confusion"
**Story:** n/a
## Objective
A silent regression in the history mechanics (a double push, a replaceState on the wrong entry, a listener leak, a lost `lastPrev`) is caught at the source level — the `test_remove_confirm_modal.py` house pattern for a frontend module whose real behavior is E2E-gated.
## Work
1. `tests/unit/test_frontend_doc_modal.py` — new file (the `test_remove_confirm_modal.py` pattern: read `frontend/assets/document-modal.js` as text — the `_js()`-style loader, REPO-relative path; if a doc-modal unit file already exists, extend it instead):
- **the state:** `let entry = null` and `let lastPrev = null` and `let closingViaBack = false` are declared exactly once (one `let` each — no second writer site);
- **the open:** the push is `history.pushState({ modal: "doc", source, path }, "")` (state-only — the `""` second arg present — the URL must not move); the push sits INSIDE the else-branch behind the `st && st.modal === "doc"` adopt check (assert the adoption branch assigns `entry = { prev: lastPrev, source, path }` WITHOUT a pushState — the no-second-push rule); the defensive re-open branch uses `history.replaceState` and keeps `entry` (updates `source`/`path`);
- **the close:** the in-place removal `history.replaceState(entry.prev, "")` is guarded by `!closingViaBack` AND `history.state && history.state.modal === "doc"` (the fail-safe — a non-modal current entry is never overwritten); `entry = null` runs unconditionally in the close (the adopt's memory must not survive a close);
- **the popstate:** the listener is registered ONCE (one `window.addEventListener("popstate"` in the file); its two branches are mutually exclusive (the `isModalEntry && modalEl.hidden` forward-re-open branch RETURNS before the back-close branch); the back-close branch writes `lastPrev = st` BEFORE the `closingViaBack`-wrapped `closeDocumentModal()` call; no branch fires when `entry` is falsy / the modal is closed and the state is not a modal entry (the pass-through);
- **no leak:** the popstate listener and the entry state are the ONLY `history.*` call sites in the file (a grep-style assert: the file's `history.` occurrences are exactly the push, the two replaces, and nothing else — `history.back()` / `history.go()` must NOT appear — the browser owns its entries);
- **the contract unchanged:** the pre-phase pins that a regression could plausibly break still hold — the Escape → `closeDocumentModal()` capture, the `fetchSeq` stale-guard lines, the trigger-focus restore in the close, the `window.open(fullPageUrl…)` fallback BEFORE the entry logic (the fallback pushes nothing — assert its early `return` precedes the first `history.` use in the open).
2. Run the frontend unit set: `uv run pytest tests/unit/ -k frontend` green (this file + every pre-existing pin).
## Testing & Quality
- Unit: the pins above (each one names the regression it catches in its docstring — the house pattern).
- Coverage: **>90%** on new/modified code (frontend — the `app/` gate stays green).
## Completion Criteria
- [ ] `tests/unit/test_frontend_doc_modal.py` green; every pre-existing frontend unit pin green (`uv run pytest tests/unit/ -k frontend`)
- [ ] the pin set catches: a second push on forward re-open, a `replaceState` on a non-modal entry, a double-registered popstate listener, a `history.back()`/`go()` introduction, a lost `lastPrev` write, a fallback-path push
- [ ] `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed work (this task is tests-only)
@@ -0,0 +1,29 @@
# Task 03 — the dedicated E2E: Back closes the modal, the history stays sane, Forward re-opens
**Phase:** `144_modal_back_button` · **Source:** `TODO.md:6` — "back button doesn't close the modal of you're looking at but instead navigates back behind the modal which can lead to confusion"
**Story:** n/a
## Objective
The browser-visible proof with the REAL history: `page.goBack()` / `page.goForward()` drive the owner's exact scenario — back closes the modal (nothing else moves), a button close leaves no dead step, forward re-opens the document.
## Work
1. `tests/e2e/test_modal_back_button.py` — dedicated Playwright file, run in isolation, mock-only (the house E2E conventions — the per-module fixture import, the login helper, the chat-chip / sources-table open paths from the phase-26 story files):
- fixture: the standard E2E KB (at least one readable document); a helper `open_modal_via_chip(page)` — asks a question whose answer carries a source chip (the mock's citation echo), clicks the chip, waits for `#doc-modal` visible + the content rendered (the "Document loaded." desc state);
- test 1 (`test_back_closes_modal_view_unchanged`): boot on `/` (chat), `open_modal_via_chip`, note `page.url` (`/`) and the visible view (`#view-chat` not hidden) → `page.goBack()` → assert `#doc-modal` is hidden (closed), `page.url` STILL `/`, `#view-chat` still visible (the view behind was NOT navigated — the owner's confusion is gone) → one more `page.goBack()` is a REAL step: it leaves the app's shell history (in the test, assert the URL moved away from `/` or the page left the shell — the point: the second back did the navigation the first back should have done; if the test boots with no prior history, skip the second-back assert and note it — the modal entry's existence is proven by test 3's forward);
- test 2 (`test_button_close_leaves_no_dead_step`): boot, `open_modal_via_chip` → close via `#doc-modal-close` (the button affordance) → assert the modal hidden → assert the history depth is RESTORED: `page.evaluate("history.length")` equals the pre-open depth (the in-place `replaceState` removed the entry — the next Back is a real navigation step, not a silent no-op). (If the boot context's `history.length` baseline is flaky across Chromium versions, fall back to the OBSERVABLE: a `page.goBack()` after the button close leaves `/` immediately — one step, not two — and assert that instead; the depth assert is preferred when stable.);
- test 3 (`test_forward_reopens_same_document`): boot, `open_modal_via_chip` (document D) → `page.goBack()` (modal closes) → `page.goForward()` → assert `#doc-modal` visible again with D's title (the same document — `#doc-modal-title` text equals the pre-back title) and the history did NOT gain an entry (`history.length` unchanged by the re-open — the adopt, not a push);
- test 4 (`test_back_from_sources_keeps_drill` — the interplay guard): boot on `/sources.html` (the RAG view, admin — the login helper), open a document via a table link (the drill at the TOP level is the simplest case), `page.goBack()` → modal hidden, URL `/sources.html`, the Sources view still visible; if the fixture supports a drilled level (a folder with the phase-99 breadcrumb), open the document FROM a drilled level and assert the breadcrumb survived the back (the drill's popstate re-adopted its own state — no visible change).
2. Run the regression set in isolation (each its own run): this file; `tests/e2e/test_document_back_navigation.py` (the phase-13 FULL-PAGE /document.html back-nav — a different page, must stay green); the phase-99 Sources drill E2E (`test_kb_tree_table_and_back_nav.py` — the `kb` entry interplay must not leak).
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_modal_back_button.py -v --no-cov` — green IN ISOLATION (DB up: `podman compose up -d db`), mock-only.
- Regression: the two named files green in isolation.
- Coverage: **>90%** on new/modified code (frontend — the `app/` gate unaffected).
## Completion Criteria
- [ ] back with the modal open → modal closed, URL unchanged, view unchanged (tests 1 + 4)
- [ ] button close → no dead history step (test 2)
- [ ] back → forward → the same document re-opens without a new history entry (test 3)
- [ ] `uv run pytest tests/e2e/test_modal_back_button.py -v --no-cov` green in isolation; both regression E2Es green in isolation
- [ ] `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the E2E is additive; no app/frontend change in this task)