phase: 153_document_viewer_context
Build and Push Containers / build-and-push-app (push) Successful in 2m45s
Build and Push Containers / build-and-push-db (push) Successful in 19s

That last `--co` (collect-only) run was my probe mistake — it shows 34% because no tests execute under collect-only. The valid coverage run was the real gate command executed earlier: `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (exit 0, all 3544 tests executed). All gates are verified.

## Phase 153 — final verification pass report

**Verified (all tasks 01–04 complete in `complete/`):**
- Modal `fullPageUrl(source, path, back = null)` + `openDocumentModal(…, back = null)` with module-level `currentBack`; 5 chat call sites pass `"/"`, sources.js call site unchanged (byte-identical Sources path); `documentUrl` comment now true
- Copy-path control (shared `renderDocument` core, icon+text, text = accessible name) on both surfaces; per-page clipboard helpers with the fallback-field + status-line contract; `#doc-copy-announcer` live region; styles + 44px touch floor
- Unit pins: `&back=%2F` / no-`back` byte-identity, `currentBack` single-writer guard, copy strings byte-pinned, phase-13 regression pins untouched

**Test / lint / coverage results:**
- `uv run pytest` → **3544 passed**, 0 failed (full unit + integration)
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90% gate)
- `uv run pytest tests/e2e/test_document_viewer_context.py -v --no-cov` → **8 passed** in 47.33s, in isolation (DB up, mock LLM)
- `uv run ruff check . && uv run pyright` → clean (0 errors/warnings)

**Completion criteria:** all met — (1) chat→modal→Full page: `href="/"` + "Chat"; Sources→modal→Full page: `href="/sources.html"` + "Sources" with `back=` asserted absent; (2) no-`back` URLs byte-identical to pre-phase; (3) Copy path: "Path copied." on success (clipboard readback), prefilled select-on-focus fallback field + "Path ready — copy it from the field." on rejected clipboard, both surfaces parameterized; (4) copied string exactly `docs/homelab/kubernetes.md`; (5) full suite green, E2E isolated green, lint clean, coverage 99%; (6) commit by harness.

**Documentation:** README.md reviewed in full — phase-153 feature already named in the Chat section; routes, env reference, workflows, deployment all consistent — no changes needed. AGENTS.md reviewed in full — rules, gates, and quick-reference commands verified against the repo (incl. `test_smoke.py`, `.env.example`, gitignore entries) — consistent, no changes needed.

**Notable:** pytest 9.1.1 quirk — a doubled `-q` (pyproject `addopts` + explicit `-q`) swallows the final "N passed" summary line while still exiting 0; the plain `uv run pytest` gate command prints it. No defects found in prior work.

**Next pending phase:** `154_sync_status_freshness`.
This commit is contained in:
2026-10-04 09:23:54 -04:00
parent 12fa69b7ea
commit 966c4b0540
59 changed files with 4304 additions and 34 deletions
@@ -0,0 +1,43 @@
# Phase 153 — the document viewer carries its origin, and the path is copyable
**Source:** owner UX audit 2026-10-03 (live interactive-browser pass): "document.html's back link always says '← Sources' even when you arrived from Chat" + optimization "a 'copy path' affordance next to the path chip would speed up the copy-paste-to-terminal workflow."
**Story:** `.agents/user_stories/document-viewer.md` (phase 10, complete) + `.agents/user_stories/document-back-navigation.md` (phase 13, complete) — this phase extends both: the phase-13 `back` contract exists on the DIRECT chip link (`app.js::documentUrl`, L447–457) but the phase-26 same-page modal's "Full page" escape hatch (`document-modal.js::fullPageUrl`, L94–97) drops it, so a chat-originated document opened through the modal lands on "← Sources" in the standalone viewer.
**Context:** `openDocumentModal(source, path, trigger = null)` (document-modal.js L167) — called from the chat view (app.js L1700, L1762, L1804, L1825, L1908 — source chips, nearby-doc links, source-image figures) and from the RAG folder table (sources.js L2023). The "Full page" anchor (L202) and the no-skeleton `window.open` fallback (L172) both use `fullPageUrl`. The standalone back-link resolution (document.js, phase 13) honors a same-origin relative `back` param (`/` prefix, not `//`) and falls back to `/sources.html` otherwise; its label matches the resolved target (Chat / Sources) — already shipped, untouched by this phase. The clipboard idiom is established (app.js L216–221, L2375–2384; history.js L297–301): `navigator.clipboard.writeText` in a try — a non-secure (http) homelab origin rejects the clipboard, so the failure path renders an inline fallback field (an input-like node that selects its content on focus) + a status line.
## Objective
A document opened from the in-page modal carries its origin to the standalone viewer (chat → back to chat, Sources → back to Sources — the phase-13 resolution, finally fed on every path), and the owner can copy the document path (`source/path`) from both the modal's meta and the standalone titlebar with one click, with the established clipboard-fallback contract.
## Dependencies
- `13_document_back_navigation` (complete) — the `back` param + same-origin validation + label-match contract this phase feeds.
- `10_story_document_viewer` (complete) — the viewer's meta row (source/format chips, mono path) the Copy-path control joins.
- `144_modal_back_button` (complete) — the modal's history-entry model; this phase adds no navigation, only a URL param on the escape hatch.
## Design (shared by all tasks — the executor reads this, not the chat)
- **The origin param (task 01):** `openDocumentModal(source, path, trigger = null, back = null)` and `fullPageUrl(source, path, back = null)` — `back` is appended as `&back=` + `encodeURIComponent(back)` ONLY when truthy (a null/absent `back` keeps the URL byte-identical to pre-phase: the Sources path is unchanged). The modal remembers the `back` of the CURRENT open (a module-level variable set at each `openDocumentModal` — the modal is one-at-a-time by its own contract, so a single variable is sound). The five chat-view call sites (app.js) pass `"/"` — chat is the shell's home route, the same value `documentUrl` already uses; the sources.js L2023 call site omits it (null → no param → the viewer's `/sources.html` default). The no-skeleton fallback (L172) uses the same `back`. The app.js L447–457 comment ("this URL is also what the modal's 'Full page' link points at") becomes TRUE — update the comment where it cites the contract, do not restate it.
- **Copy path (task 02):** ONE control shape on both surfaces — an icon + "Copy path" text button (visible text, `aria-label` "Copy the document path" is redundant with the text so it carries the plain text; icon `aria-hidden`), placed in the standalone titlebar's meta row after the mono path and in the modal's meta block. The copied string is `source + "/" + path` (ASSUMPTION A1 — the exact `source/path` form the `read` tool, the chips, and the Sources table use; the paste target is the owner's own tooling). Clipboard contract (the share-link-fallback idiom, app.js L216–221): `navigator.clipboard.writeText` in a try → success: a transient status "Path copied." (visible near the control + the page's live region; auto-clears ~4 s, the toast idiom) → failure: an inline fallback field (input-like, prefilled with the path, selects its content on focus — the `.share-link-fallback` pattern) + the status line "Path ready — copy it from the field." (the owner-locked fallback wording, adapted for the path). Per-page duplication of the small helper is the established idiom (history.js L301) — document.js and document-modal.js each carry their own; no new shared module.
- **A11y (both tasks):** the standalone page already has the landmarks/label frame (phase 10/13); the new button is a labeled control (WCAG 1.3.1/4.1.2); the status line is `role="status"`; the fallback field is a labeled input-like node (the share-fallback precedent). No new color-alone state (B5).
## Tasks
1. `01_modal_back_context.md` — the `back` param through `openDocumentModal`/`fullPageUrl` + the six call-site updates + the comment correction.
2. `02_copy_path.md` — the Copy-path control on the standalone titlebar + the modal meta, clipboard idiom + fallback field + status line.
3. `03_unit_pins.md` — the URL-contract pins (with/without `back`), the modal param default, the copy-path strings + fallback contract.
4. `04_e2e_viewer_context.md` — the dedicated Playwright suite: chat→modal→Full page back-to-chat, Sources→modal→Full page back-to-Sources, Copy path success + fallback.
## Testing & Quality
- Unit: `tests/unit/` source-level pins — `fullPageUrl` byte-identical without `back`, `&back=%2F` appended with `back = "/"` (encoded); `openDocumentModal`'s default is null (the Sources path unchanged); the copy-path status strings pinned byte-for-byte (success + fallback); the phase-13 resolution pins (a garbage `back` still falls back to `/sources.html` — the regression guard).
- Integration: the `/api/documents/content` + `GET /document.html` contract is untouched (no new server surface — the phase is frontend-only; the server pins are regression guards only).
- Coverage: **>90%** on modified code (JS pinned by the source-level suites + the E2E).
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's).
- E2E: `uv run pytest tests/e2e/test_document_viewer_context.py -v --no-cov` in isolation (DB up: `podman compose up -d db`; the deterministic `tests/e2e/mock_llm.py` — a citation-bearing answer renders the chips).
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by the phase**.
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are user-visible (the Copy-path affordance is a user-visible feature — name it where the viewer is described).
## Completion Criteria
- [ ] chat view → source chip → modal → "Full page": the standalone back link is `href="/"` with the "Chat" label; Sources table → modal → "Full page": `href="/sources.html"` with the "Sources" label (E2E)
- [ ] a direct Sources table chip (no `back` param) and the pre-phase URL are byte-identical — no regression on the phase-13/26 paths
- [ ] Copy path on both surfaces: clipboard success reads "Path copied."; a rejected clipboard renders the prefilled fallback field + "Path ready — copy it from the field." (E2E pins both branches)
- [ ] the copied string is exactly `source/path` (the `read`-tool form)
- [ ] `uv run pytest` green; the dedicated E2E green in isolation; `uv run ruff check . && uv run pyright` clean; `app/` coverage >90%
- [ ] one atomic commit per AGENTS.md rule 8 (`--no-gpg-sign`)
@@ -0,0 +1,24 @@
# Task 01 — the modal's "Full page" escape hatch carries the origin `back`
**Phase:** `153_document_viewer_context` · **Story:** `.agents/user_stories/document-back-navigation.md`
## Objective
A document opened from the in-page modal reaches the standalone viewer with the same `back` context the direct chip link already carries (phase 13) — chat-originated opens land back on chat, Sources-originated opens keep the `/sources.html` default.
## Work
1. `frontend/assets/document-modal.js`:
- `fullPageUrl(source, path, back = null)` (L94): append `"&back=" + encodeURIComponent(back)` when `back` is truthy — the encoding contract is the one `app.js::documentUrl` (L447–457) already documents (percent-encoded query values; `back` is a same-origin relative URL, never a URL with its own query).
- `openDocumentModal(source, path, trigger = null, back = null)` (L167): store `back` in a module-level variable (the modal is one-at-a-time by its own contract — a single variable is sound; name it `currentBack` and reset it at the top of every open); the no-skeleton fallback (L172) and the "Full page" anchor (L202: `openEl.href = fullPageUrl(source, path, currentBack)`) both read it.
- Update the L39 and L93–97 comment blocks where the "Full page" URL is described — the contract now matches `documentUrl`'s (cite it, do not restate it).
2. `frontend/assets/app.js` — the five chat-view call sites pass `"/"` (chat is the shell's home route, the value `documentUrl` already uses): L1700 (`openDocumentModal(s.source, s.path, chip)`), L1762 (`… link)`), L1804 (`… fig)`), L1825 (`… chip)`), L1908 (`… fig)`).
3. `frontend/assets/sources.js` L2023 (`openDocumentModal(d.source, d.path, link)`) — UNCHANGED (null → no param → the viewer's `/sources.html` default). Do not touch it; the byte-identity is the contract.
4. `frontend/assets/app.js` L447–457: correct the comment where it says the chip URL "is also what the modal's 'Full page' link points at" — after this task that is true on the chat path (the `back` param rides along); name the fix, keep the existing contract language.
## Testing & Quality
- Unit: the source-level pins in the frontend test suite gain: `fullPageUrl("s", "p")` byte-identical to pre-phase (no `back`); `fullPageUrl("s", "p", "/")` === `"/document.html?source=s&path=p&back=%2F"`; `openDocumentModal` with no `back` arg leaves the Sources path byte-identical; the no-skeleton fallback uses the stored `back`.
- Coverage: **>90%** on the modified JS (source-level pins).
## Completion Criteria
- [ ] chat chip → modal → "Full page" URL carries `&back=%2F`; Sources table → modal → "Full page" URL is byte-identical to pre-phase
- [ ] `uv run pytest tests/unit -q` green; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in the phase-13/26/144 paths (the direct chip link, the context-menu escape hatch, the modal back-close)
@@ -0,0 +1,25 @@
# Task 02 — Copy path on the standalone titlebar and the modal meta
**Phase:** `153_document_viewer_context` · **Story:** `.agents/user_stories/document-viewer.md`
## Objective
The owner copies the document path with one click on both surfaces — the standalone viewer's titlebar meta row and the modal's meta block — with the established clipboard + inline-fallback contract (a non-secure homelab origin rejects `navigator.clipboard`, so the failure path must always work).
## Work
1. `frontend/document.html` — the titlebar meta row (phase 10/13, L108–113 area): after the mono path cell, add the Copy-path control — an icon (`aria-hidden`) + visible "Copy path" text (the text IS the accessible name; no redundant `aria-label` needed). Follow the icon+text button house pattern (the Sources table's "Ignore paths"/"Remove" buttons are the shape reference).
2. `frontend/assets/document.js` — the control's behavior (a page-local helper, the per-page duplication idiom — history.js L301):
- the copied string is `source + "/" + path` (ASSUMPTION A1 — the exact `source/path` form the `read` tool and the chips use; the same two values the page already renders in the meta row);
- `navigator.clipboard.writeText(…)` in a try: success → a transient visible status "Path copied." near the control (auto-clears ~4 s, the toast idiom — the node is visual-only) + the page's live region reads the same text (one a11y announcer, the share pattern: app.js L219–221); failure → an inline fallback field (an input-like node, prefilled with the path, selects its full content on focus — the `.share-link-fallback` pattern) + the status line "Path ready — copy it from the field." (the owner-locked fallback wording, path-adapted). A failed copy NEVER clears the previous state; re-clicking re-attempts.
- the live region: reuse the page's existing `role="status"` surface where one exists; if the standalone page has none, add one visually-hidden node (the a11y frame is otherwise phase-10's — do not restructure it).
3. `frontend/assets/document-modal.js` — the modal's meta block: the SAME control shape + the same page-local helper (per-page duplication — the modal module is separate). The modal already has `statusLine(text)` (L100) for its loading states — the success/failure status reuses that node shape (`role="status"`), and the fallback field is placed in the meta block next to the control.
4. `frontend/assets/styles.css` — the control's styles (icon+text button, the house pattern) + the fallback field (input-like, select-on-focus is JS; the styling mirrors `.share-link-fallback`). Theme-aware (built-in variables only — no new palette entry, B3/B4: an all-default theme stays byte-identical in the served HTML… the control is in `document.html`/`index.html` markup — it is ALWAYS present, so check the B4 byte-identical contract: the control is a new static node in the built-in pages; B4's contract is about THEME no-ops (no `ui_settings` row → no `#bor-theme` tag), not about page content — the new static node is fine, but the unit test that parses `document.html`/`index.html` structure (if any) must gain the node).
## Testing & Quality
- Unit: source-level pins — the copied string is `source + "/" + path` (pinned byte-for-byte for a path with spaces + slashes); the success status string "Path copied."; the fallback field's prefill + select-on-focus contract; the failure status string "Path ready — copy it from the field."; a rejected-clipboard E2E-mock (the suite's http-origin idiom) renders the fallback field.
- Coverage: **>90%** on the modified JS (source-level pins + E2E).
## Completion Criteria
- [ ] both surfaces: click → clipboard success → "Path copied." (visible + live region); clipboard rejected → prefilled fallback field + "Path ready — copy it from the field."
- [ ] the fallback field's content selects on focus (the share-link-fallback idiom)
- [ ] the a11y frame is intact: labeled control, `role="status"` announcer, no color-alone state
- [ ] `uv run pytest tests/unit -q` green; `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,23 @@
# Task 03 — unit pins for the URL contract and the copy-path strings
**Phase:** `153_document_viewer_context` · **Story:** n/a
## Objective
The two contracts of this phase — the `back` param on the modal's full-page URL, and the copy-path status/fallback strings — are pinned at the source level so a later refactor cannot silently drop the origin or break the fallback contract.
## Work
1. `tests/unit/` — locate the existing frontend source-level pin suite (the `document-modal.js` / `document.js` / `app.js` pins — the phase-13/144 suites are the precedent) and extend it:
- `fullPageUrl`: no-`back` byte-identical to pre-phase; `back = "/"` → `&back=%2F` appended (the exact string pinned); `back = null` and absent are identical;
- `openDocumentModal`: default `back` is null; a chat open (`back = "/"`) is what the "Full page" anchor and the no-skeleton fallback use;
- copy-path: the composed copy string for a `source`/`path` pair with spaces + slashes (pinned byte-for-byte); the success string; the fallback field prefill; the failure string;
- regression guard: the phase-13 resolution pins stay green (a missing/garbage/protocol-relative `back` falls back to `/sources.html`; a same-origin relative `back` wins — do NOT modify those pins, only add beside them).
2. If `document.html`'s structure is pinned by a unit test (check the existing suite — the no-CDN test pins `index.html` per AGENTS.md rule 6; check whether a `document.html` pin exists), gain the Copy-path node.
## Testing & Quality
- Unit: the pins above; the suite's existing pins all stay green (no pin was deleted or loosened — additions only).
- Coverage: the pins exercise the modified branches; `app/`-side code is untouched (frontend-only phase).
## Completion Criteria
- [ ] `uv run pytest tests/unit -q` green — the new pins + every pre-existing pin
- [ ] the no-`back` URL pin is byte-identical to the pre-phase string (the byte-identity contract, the phase-13 precedent)
- [ ] `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,25 @@
# Task 04 — the dedicated Playwright suite: origin back + copy path
**Phase:** `153_document_viewer_context` · **Story:** n/a (a phase-specific suite — `tests/e2e/test_document_viewer_context.py`)
## Objective
An isolated E2E proves the two user-facing contracts: a document opened through the modal from Chat lands back on Chat (and from Sources on Sources) after "Full page", and Copy path works on both surfaces — including the rejected-clipboard fallback branch.
## Work
1. `tests/e2e/test_document_viewer_context.py` — the dedicated file (run in isolation per AGENTS.md rules 4/9; the fixture idiom from the neighboring suites: the mock-LLM server by default, the DB truncate/re-import per module):
- **test_chat_origin_full_page_backs_to_chat:** the mock-LLM answer renders a citation-bearing bubble (the mock's standard grounded turn); the chat view's source chip is clicked (the phase-26 intercept opens the modal); "Full page" is clicked (new tab — the Playwright context's second page); on the standalone page: `#doc-back`'s `href === "/"` AND its text is "Chat" (the phase-13 label-match contract, now actually fed).
- **test_sources_origin_full_page_backs_to_sources:** the RAG folder table's path link is clicked (the modal); "Full page"; the standalone `#doc-back` `href === "/sources.html"` + text "Sources" (the default — the URL carries NO `back` param; assert the param's ABSENCE too).
- **test_direct_chip_link_still_carries_back (regression guard):** a context-menu / middle-click on the chat chip (the `documentUrl` escape hatch, pre-phase behavior) still lands with `&back=%2F` — the phase-13 path is untouched.
- **test_copy_path_clipboard_success:** the standalone titlebar's Copy path is clicked with the clipboard permitted (the suite's https-origin idiom, or a granted clipboard mock): the status "Path copied." is visible + the live region reads it; the clipboard content is exactly `source/path`.
- **test_copy_path_fallback:** the clipboard is rejected (the suite's http-origin idiom — the established pattern): the fallback field appears, prefilled with the path, and focuses+selects its content on focus; the status reads "Path ready — copy it from the field."; the modal surface branch is covered by the same two tests run against the modal's control (parameterize the surface: standalone / modal).
- **test_direct_open_without_back_defaults (regression guard):** `GET /document.html?source=…&path=…` (no `back`, e.g. a shared-chat document link) still resolves to `/sources.html` + "Sources" — the phase-13 fallback.
2. The suite boots exactly like the neighboring story suites (the app under the mock LLM; a saved chat with a citation-bearing answer is seeded the same way `test_document_viewer.py` / `test_document_back_navigation.py` do — read those two files for the fixture idiom and mirror it, do not invent a new one).
## Testing & Quality
- E2E: the file above, run in isolation: `uv run pytest tests/e2e/test_document_viewer_context.py -v --no-cov` (DB up: `podman compose up -d db`).
- The suite is DETERMINISTIC: mock-LLM answers only (no `E2E_REAL_LLM` dependency), fixed fixture document (the test-KB document with a known `source/path` — the copied-string assertion is a byte-for-byte pin).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_document_viewer_context.py -v --no-cov` green in isolation
- [ ] every contract of the phase's Completion Criteria is asserted by the suite (the origin back on both surfaces, the label match, the `back` absence on the Sources path, the copy string, both clipboard branches)
- [ ] the suite adds no real-LLM dependency and no fixture beyond the established idiom
@@ -0,0 +1,44 @@
# Phase 154 — sync + upload stamps say WHEN (date-qualified freshness)
**Source:** owner UX audit 2026-10-03 (live interactive-browser pass): "RAG 'Synced 16:06' badge is time-only — ambiguous the next day" + "Sources 'Uploaded — press Sync sources to import it.' has no timestamp — it's server-persisted and re-rendered on every mount; after that archive is imported by a sync, the line can sit there indefinitely and mislead."
**Story:** n/a (audit-derived phase)
**Context:** `sources.js::fmtSyncTime(iso)` (L814–819) — `HH:MM` only; its consumer `applySyncSuccess` (L1195–1196) settles the RAG sync button as `Synced ${time}` (or bare "Synced" when the ISO is unparseable — the app-restart case: the sync status is in-memory, A12). The label parts writer (L821 onward) carries the same text in the button `title` + the running `aria-label` — the qualified stamp rides all three (the phase-128 parts-writer idiom: write once, three surfaces). `git-sources.js::fmtUploadResult(detail, name)` (L1128–1133) — the no-count contract (phase 90): `Uploaded <name> — press Sync sources to import it.` (fresh 202 settle, the page-local `lastUploadName`) or the nameless `Uploaded — press Sync sources to import it.` (the boot re-render, `initUploadStatus` L1279–1304, where the name is unknown after a reload). `GET /api/git-sources/upload/status` (git_sources.py L704–729) ALREADY exposes `started_at` / `finished_at` (ISO) + `detail` — the server change is ZERO; this phase is frontend-only. The per-page duplication idiom is established (history.js L301's copyText, sources.js vs app.js) — the date-qualifier helper is duplicated into the two modules, not shared.
## Objective
Both freshness stamps tell the owner *when*: a same-day stamp stays `HH:MM` (byte-identical to today's behavior); a non-same-day stamp carries the date (`M/D, h:MM AM/PM` — the table-date family); the Sources upload line carries its completion stamp on every render path (fresh settle AND boot re-render), so a stale "Uploaded" line is self-dating.
## Dependencies
- `150_drop_sync_result` (complete) — the settled sync button is the sole "Synced" surface (the phase-149 box is the running label; the button settles it).
- `64_sync_upload_progress` (complete) — the upload 202/poll/re-attach state machine this phase's line change rides on.
- `90_upload_no_scan` (complete) — the no-count result-line contract (phase 90, A2) this phase EXTENDS (a stamp is added; no counts return).
## Design (shared by all tasks — the executor reads this, not the chat)
- **The date-qualifier rule (ASSUMPTION A1, owner-locked):** the stamp is computed from the ISO's LOCAL date (the page's clock — the table dates already use local time). Same local year as now AND same calendar day → `HH:MM` (byte-identical to the current `fmtSyncTime` output — a same-day badge is unchanged). Otherwise, same year → `M/D, h:MM AM/PM` (e.g. `10/2, 3:14 PM`); different year → `M/D/YYYY, h:MM AM/PM` (e.g. `1/3/2025, 9:12 PM`). An unparseable/missing ISO → the existing fallback (sync: bare "Synced"; upload: no stamp — the pre-phase strings). `h:MM` uses the locale 12-hour form WITHOUT leading zero on the hour (matching the table dates' `5:34:06 PM` family — drop the seconds), month/day without leading zero (the tables' `M/D/YYYY` shape).
- **RAG sync badge (task 01):** `fmtSyncTime` becomes the date-qualifier (keep the name — its contract widens; or add `fmtStamp` and delegate — the executor's choice, the pins fix the output). `applySyncSuccess` is unchanged in shape (`settleSyncButton(time ? \`Synced ${time}\` : "Synced")` — the qualifier lives inside the formatter). The parts writer's comment block (L821 onward) names the qualified stamp in the label parts where the `HH:MM` contract is cited (cite, don't restate).
- **Sources upload line (task 02):** `fmtUploadResult(detail, name, when)` — `when` is the precomputed stamp string (the caller computes it from `status.finished_at`, falling back to `status.started_at`; the success state always has a `finished_at` on the server, the fallback is the defensive guard for a legacy/partial payload). Shapes (the phase-90 contract extended, name present/absent): `Uploaded <name> (<stamp>) — press Sync sources to import it.` / `Uploaded <stamp> — press Sync sources to import it.`; `when` empty (running state, legacy payload) → BYTE-IDENTICAL to the current strings (the no-stamp fallback). Call sites: the poll settle (L1239–1244, the page-local `lastUploadName`) and `initUploadStatus`'s success branch (L1279–1304, nameless) — both read `status.finished_at` from the same `/upload/status` payload. The L78–96 and L1125–1133 comment blocks name the stamp in the contract (cite, don't restate).
- **A11y:** the stamps are text in existing `role="status"` / button-label surfaces — no new nodes, no color-alone state (B5).
## Tasks
1. `01_rag_sync_stamp.md` — the date-qualifier in `sources.js` (`fmtSyncTime`'s contract widens; same-day byte-identical).
2. `02_upload_line_stamp.md` — the `when` param on `fmtUploadResult` + both call sites reading `finished_at`/`started_at`.
3. `03_unit_pins.md` — the qualifier's boundary pins + both stamp shapes (with/without name, with/without stamp).
4. `04_e2e_status_freshness.md` — the dedicated Playwright suite.
## Testing & Quality
- Unit: source-level pins — the qualifier (same-day `HH:MM` byte-identical; yesterday `M/D, h:MM AM/PM`; last-year `M/D/YYYY, h:MM AM/PM`; garbage/missing ISO → the pre-phase fallbacks — a fixed clock in the test, not wall-clock math on `new Date()`); `fmtUploadResult`'s four shapes (name×stamp, name×no-stamp byte-identical, no-name×stamp, no-name×no-stamp byte-identical); the `applySyncSuccess` settle string for a non-same-day `finished_at`.
- Integration: ZERO server change — the `/upload/status` payload is untouched (regression guard: the full-dict pin, if one exists, stays byte-identical).
- Coverage: **>90%** on the modified JS (source-level pins + E2E).
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's).
- E2E: `uv run pytest tests/e2e/test_sync_status_freshness.py -v --no-cov` in isolation (DB up: `podman compose up -d db`).
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by the phase**.
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are user-visible (the stamp format is a visible UI change — name it where the sync/upload surfaces are described).
## Completion Criteria
- [ ] a same-day sync settles as `Synced HH:MM` — byte-identical to pre-phase (the pin + the E2E)
- [ ] a non-same-day `finished_at` (the unit pins; the E2E asserts the same-day shape on a real mock sync — the date branch is unit-pinned with a fixed clock, the E2E never depends on the wall clock)
- [ ] the upload line carries the stamp on BOTH render paths (the fresh 202 settle with the name; the boot re-render without it) — the E2E seeds a prior upload via the API and reloads the page
- [ ] a running/legacy upload payload renders the pre-phase strings byte-identically (no partial-state regression)
- [ ] `uv run pytest` green; the dedicated E2E green in isolation; `uv run ruff check . && uv run pyright` clean; `app/` coverage >90%
- [ ] one atomic commit per AGENTS.md rule 8 (`--no-gpg-sign`)
@@ -0,0 +1,23 @@
# Task 01 — the RAG sync badge is date-qualified
**Phase:** `154_sync_status_freshness` · **Story:** n/a
## Objective
The settled RAG sync button reads `Synced HH:MM` when the run finished today and `Synced M/D, h:MM AM/PM` (or `M/D/YYYY, …` across years) otherwise — a badge that no longer lies about its age.
## Work
1. `frontend/assets/sources.js`:
- `fmtSyncTime(iso)` (L814–819): the contract widens to the date-qualifier (the phase design's A1 rule). Keep the function name if the delegation is clean; otherwise add `fmtStamp(iso)` and let `fmtSyncTime` delegate — the pins fix the OUTPUT, the executor's choice is internal. The parse guard (`Number.isNaN(d.getTime()) → ""`) is UNCHANGED — an unparseable ISO still yields the bare "Synced" settle.
- the qualifier compares LOCAL calendar dates (the page's clock — the tables' `Updated` columns already render local dates; the idiom is there in `history.js`'s updated-cell formatter — mirror its `toLocaleDateString`/manual shape, the `M/D(/YYYY), h:MM AM/PM` family with no leading zeros).
- `applySyncSuccess` (L1195–1196) is UNCHANGED in shape — the qualifier lives inside the formatter; the settle string for a same-day run is byte-identical to pre-phase.
- the parts-writer comment block (L821 onward) names the qualified stamp where the `HH:MM` label contract is cited (cite, don't restate — the block already lists the label parts; the badge is one of them).
2. No CSS change (the label part ellipsizes; the longer stamp is just more text in the same part).
## Testing & Quality
- Unit: the source-level pins gain the qualifier's boundary set with a FIXED clock (the test's fake clock — never `new Date()` in the test body): same-day → `HH:MM` (byte-identical to the pre-phase pin); yesterday (same year) → `M/D, h:MM AM/PM`; last-year same M/D → `M/D/YYYY, h:MM AM/PM`; hour formatting (`3:14 PM` — no leading zero on the hour; `12:05 AM` edge); garbage ISO → `""` (unchanged).
- Coverage: **>90%** on the modified JS.
## Completion Criteria
- [ ] same-day settle is byte-identical to pre-phase (`Synced HH:MM`)
- [ ] the boundary pins pass on a fixed clock; the wall clock never appears in a test assertion
- [ ] `uv run pytest tests/unit -q` green; `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,28 @@
# Task 02 — the Sources upload line carries its completion stamp on every render path
**Phase:** `154_sync_status_freshness` · **Story:** n/a
## Objective
`Uploaded — press Sync sources to import it.` becomes self-dating — `Uploaded 21:14 — …` (or `Uploaded 10/2, 3:14 PM — …`) on BOTH render paths (the fresh 202 settle with the page-local name, the boot re-render without it) — so a line that outlives its upload can no longer masquerade as fresh.
## Work
1. `frontend/assets/git-sources.js`:
- `fmtUploadResult(detail, name, when)` (L1128–1133): the third param is the PRECOMPUTED stamp string (the phase design's A1 format, computed by a page-local `fmtUploadStamp(status)` helper — the per-page duplication idiom; it reads `status.finished_at` first, falls back to `status.started_at`, and yields `""` when both are missing/absent). Shapes:
- `detail.message === "uploaded"` + `name` + `when`: `Uploaded ${name} (${when}) — press Sync sources to import it.`
- `detail.message === "uploaded"` + no `name` + `when`: `Uploaded ${when} — press Sync sources to import it.`
- `when === ""` (any name state): BYTE-IDENTICAL to the current strings (the legacy/partial-payload guard — the phase-90 contract for a payload without timestamps).
- the non-`uploaded` branch (`"The upload finished."`) is unchanged.
- the poll settle (L1239–1244): `fmtUploadResult(detail, lastUploadName, fmtUploadStamp(status))` — the poll tick already holds the full status object.
- `initUploadStatus`'s success branch (L1279–1304): the same third arg from the boot-fetched status (the name stays null — the page-local `lastUploadName` is null after a reload; the stamp comes from the SERVER payload, so the nameless line is still self-dating after a reload — that is the point).
- the L78–96 and L1125–1133 comment blocks name the stamp in the no-count contract (cite, don't restate — the blocks already document the nameless re-render; add the stamp to the described string shape).
2. NO server change: `GET /api/git-sources/upload/status` already exposes `started_at` / `finished_at` (git_sources.py L704–729) — do not touch `app/` in this task.
## Testing & Quality
- Unit: the source-level pins gain the four `fmtUploadResult` shapes (name×stamp, name×no-stamp → byte-identical to the pre-phase string, no-name×stamp, no-name×no-stamp → byte-identical) + `fmtUploadStamp` (finished wins over started; started when finished is missing; `""` when both absent; a garbage ISO → `""`).
- Coverage: **>90%** on the modified JS.
## Completion Criteria
- [ ] the four shape pins pass; the two no-stamp shapes are byte-identical to pre-phase
- [ ] `initUploadStatus`'s success branch passes the stamp (a boot re-render of a prior upload shows the stamp even with the name unknown)
- [ ] `app/` is untouched in this task (a `git diff --name-only` scoped check)
- [ ] `uv run pytest tests/unit -q` green; `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,22 @@
# Task 03 — unit pins for the qualifier and the four stamp shapes
**Phase:** `154_sync_status_freshness` · **Story:** n/a
## Objective
The date-qualifier rule (A1) and both stamp line shapes are pinned at the source level on a fixed clock — a later refactor cannot silently revert to time-only or break the byte-identity fallbacks.
## Work
1. `tests/unit/` — locate the existing `sources.js` / `git-sources.js` source-level pin suite (the phase-64/90/150/152 suites are the precedent — read `test_frontend_sync_upload.py` and its siblings for the idiom) and extend it:
- the qualifier (task 01's boundary set — fixed clock; the byte-identity of the same-day output against the PRE-PHASE pinned string — pin the old string verbatim in the test so the byte-identity is assertable, not assumed);
- `fmtUploadResult`'s four shapes (task 02 — including the two byte-identity fallbacks pinned against the pre-phase strings);
- `fmtUploadStamp` (finished/started/missing/garbage).
2. The `GET /api/git-sources/upload/status` payload pin (if the suite pins the full dict — the phase-64 precedent): UNCHANGED — this phase adds no server keys; the pin stays byte-identical (a regression guard that task 02 really touched nothing in `app/`).
## Testing & Quality
- Unit: the pins above; every pre-existing pin stays green (additions only — no pin deleted or loosened).
- Coverage: the pins exercise the modified branches.
## Completion Criteria
- [ ] `uv run pytest tests/unit -q` green — the new pins + every pre-existing pin
- [ ] the two byte-identity fallback pins assert against the verbatim pre-phase strings
- [ ] `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,22 @@
# Task 04 — the dedicated Playwright suite: date-qualified stamps
**Phase:** `154_sync_status_freshness` · **Story:** n/a (a phase-specific suite — `tests/e2e/test_sync_status_freshness.py`)
## Objective
An isolated E2E proves the two visible contracts on the live UI: a fresh mock sync settles the RAG button as `Synced HH:MM` (same-day shape), and the Sources upload line carries its stamp on BOTH render paths — the fresh settle (with the file name) and the boot re-render after a reload (nameless, still stamped from the server payload).
## Work
1. `tests/e2e/test_sync_status_freshness.py` — the dedicated file (run in isolation per AGENTS.md rules 4/9; the fixture idiom from the neighboring sync suites — `test_sync_image_progress.py` (phase 152) and the phase-90/64 suites are the precedents; read one and mirror its boot, do not invent a new fixture):
- **test_fresh_sync_settles_same_day_shape:** a mock sync run (the suite's sync fixture — a small fixture source; the mock-LLM makes the run fast) → the RAG sync button settles; assert the label matches `Synced \d{2}:\d{2}` (the same-day shape — the test runs "now", so the qualifier must yield `HH:MM`; a regex pin, NOT a wall-clock comparison — the suite never asserts on the actual time value).
- **test_upload_line_fresh_settle_carrys_name_and_stamp:** a real small fixture archive is uploaded through the UI (the phase-90 suite's upload idiom — a generated zip in `tmp_path`) → the 202 toast → the poll settles → the ready-for-sync line reads `Uploaded <name> (<stamp>) — press Sync sources to import it.` (the name is the fixture's safe name; the stamp matches the `(\d{2}:\d{2}|\d{1,2}/\d{1,2}(?:/\d{4})?, \d{1,2}:\d{2} [AP]M)` shape pin — same-day in practice, the shape pin covers both).
- **test_upload_line_boot_rerender_stamped_nameless:** continue from the previous state (or re-seed): RELOAD the Sources view/page → the boot re-render (`initUploadStatus`'s success branch) shows the NAMELESS line WITH the stamp: `Uploaded <stamp> — press Sync sources to import it.` (the name is gone after the reload — the phase-90 contract — but the stamp survives because it comes from the server payload: THIS is the regression the phase fixes).
- **test_running_upload_line_unchanged (regression guard):** a running upload (the poll's processing state) never renders the ready-for-sync line (the phase-90 contract — the line appears only on settle; assert the bare "Processing…" label during the run with the suite's slow-LLM/delay idiom if a deterministic in-flight observation is needed, otherwise the settle-only assertion).
2. The suite is DETERMINISTIC: mock-LLM only, no `E2E_REAL_LLM` dependency; time assertions are SHAPE pins (regex), never value comparisons against the wall clock.
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_sync_status_freshness.py -v --no-cov` in isolation (DB up: `podman compose up -d db`).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_sync_status_freshness.py -v --no-cov` green in isolation
- [ ] the boot-re-render test proves the nameless line carries the stamp (the phase's actual fix)
- [ ] the suite asserts shapes, not wall-clock values; no real-LLM dependency
@@ -0,0 +1,42 @@
# Phase 155 — folder summaries in varied, natural language
**Source:** owner UX audit 2026-10-03 (live interactive-browser pass): "All `lite` folder descriptions open with 'This folder contains…' — formulaic; a prompt nudge for variety would help."
**Story:** n/a (audit-derived phase; the folder-summary machinery is phase 94's — `94_ls_tree_drilldown` (complete))
**Context:** `app/rag/folder_summaries.py::FOLDER_SUMMARY_INSTRUCTION` (L102–108) — the current neutral instruction: "From the document list below, write a 1-3 sentence plain-text summary of what this folder's documents cover, in natural language. Do not use markdown. Do not invent anything that is not in the list." The `FOLDER_SUMMARY_MODE` marker (L91) and the `FOLDER_HEADER_PREFIX` (`"Folder: "`) are the E2E mock's keys — `tests/e2e/mock_llm.py` keys on the marker + the header line (a canned reply that is a pure function of the request), NOT on the instruction text — an instruction change is mock-safe as long as the marker + header prefix stay byte-identical. `generate_folder_summaries(db, llm, *, skip, only_missing, changed_docs, on_progress)` (L440) — `changed_docs=None` is the FULL regeneration (every candidate folder; the `scripts/import_docs.py` CLI's default); the sync path is change-gated (phase 127). PLAN's "persona text changes through the plan, not in code" does NOT bind this constant — it is a one-shot prompt constant (the phase-94/127 idiom), not the chat persona; the marker contract is the load-bearing piece and it is untouched. LLM calls are free (A21) — a full regeneration's cost is not a constraint.
## Objective
New and regenerated folder summaries open in varied, natural language — no more catalogue of "This folder contains…" — via an instruction nudge, plus a one-shot `scripts/regen_folder_summaries.py` that refreshes every stored summary against the new instruction (the live RAG catalogue's 15+ source-root rows currently all carry the formulaic opener).
## Dependencies
- `94_ls_tree_drilldown` (complete) — the folder-summary machinery (grouping, prompt, upsert, prune).
- `127_sync_incremental_folder_summaries` (complete) — the `changed_docs` mode contract the new script's FULL mode (`changed_docs=None`) must not disturb.
## Design (shared by all tasks — the executor reads this, not the chat)
- **The nudge (task 01):** `FOLDER_SUMMARY_INSTRUCTION` gains the variety direction: keep the 1–3 sentence / plain-text / no-markdown / no-invention constraints UNCHANGED; add: "Open in a varied, natural way — state what the folder is for, the way a person would describe it. Do NOT begin with 'This folder contains' or 'The <folder name> folder contains'." (ASSUMPTION A1, owner-locked — the exact wording above; the executor may polish grammar but not the two forbidden openers, which are the pin.) `FOLDER_SUMMARY_MODE` and `FOLDER_HEADER_PREFIX` stay byte-identical (the mock contract — a unit pin asserts it).
- **The regen script (task 02):** `scripts/regen_folder_summaries.py` — the `scripts/import_docs.py` entrypoint idiom (argparse; the app settings/`get_settings()` fail-loud boot; the DB session construction; the `LLMClient` construction — read `scripts/import_docs.py`'s main path and mirror its exact setup, the `--limit N` debug passthrough included if import_docs has one — check and match). Body: `await generate_folder_summaries(session, llm)` — FULL mode (`changed_docs=None` the default), `only_missing=False` (a REGEN — every stored row is refreshed, not just gaps), `skip=False`. End: the importer's cron-safe single summary log line idiom (one line: counts out of the returned stats dict) + the per-`uv run` exit code 0 only on success. NO new API surface, NO schema change, NO change to the sync path's change-gated behavior (phase 127's contract is untouched — the script is a standalone maintenance entrypoint).
- **The mock (task 04's suite prerequisite):** `tests/e2e/mock_llm.py`'s CANNED folder-summary reply may evolve (it is a pure function of the request — the marker/header keys are unchanged); its canned text for a folder is updated to the new voice (e.g. "Documentation for <folder>, covering <first-doc-theme> …" — a natural opener, never the forbidden ones) so the E2E can assert the stored text's shape. The mock's OTHER markers/contracts are untouched.
## Tasks
1. `01_prompt_nudge.md` — the instruction update + the marker/header byte-identity pins.
2. `02_regen_script.md` — `scripts/regen_folder_summaries.py` (full mode, the import_docs idiom).
3. `03_unit_pins.md` — the instruction pin, the mock-contract guard, the script wiring.
4. `04_e2e_folder_voice.md` — the dedicated Playwright suite (the mock's new canned voice + the RAG table rendering it).
## Testing & Quality
- Unit: `FOLDER_SUMMARY_INSTRUCTION` pinned (the two forbidden openers present as the constraint; the 1–3 sentence/plain-text/no-markdown/no-invention words preserved); `FOLDER_SUMMARY_MODE` + `FOLDER_HEADER_PREFIX` byte-identical (the mock contract); the script's wiring (a fake LLM + a fixture catalogue: `generate_folder_summaries` called with `changed_docs=None`, `only_missing=False`, `skip=False` — the stats dict surfaces in the summary line; a bad settings boot fails loud — the app's validator idiom).
- Integration: a full-mode regeneration over a fixture catalogue upserts every candidate row (the phase-94 idiom) and prunes nothing (no catalogue change — the prune rule is count-driven; a pure regen touches only `updated_at` + text).
- Coverage: **>90%** on new/modified code (`app/rag/folder_summaries.py`, the script).
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's).
- E2E: `uv run pytest tests/e2e/test_folder_summary_voice.py -v --no-cov` in isolation (DB up: `podman compose up -d db`; the deterministic `tests/e2e/mock_llm.py`).
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by the phase**.
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are user-visible (the new script is a user-visible command — it joins the README's import/update workflow list beside `import_docs`; the summary voice is a visible content change).
## Completion Criteria
- [ ] `FOLDER_SUMMARY_MODE` + `FOLDER_HEADER_PREFIX` are byte-identical to pre-phase (the mock contract pin)
- [ ] `uv run python -m scripts.regen_folder_summaries` runs a FULL regeneration (the stats summary line logs; exit 0; every candidate row's `updated_at` advances, unchanged folders' text is the mock's new voice)
- [ ] the sync path's change-gated behavior is byte-identical (phase 127's pins stay green — no `app/api/sync.py` change)
- [ ] the E2E: a mock-LLM folder summary is stored + rendered in the RAG table WITHOUT the forbidden openers
- [ ] `uv run pytest` green; the dedicated E2E green in isolation; `uv run ruff check . && uv run pyright` clean; `app/` coverage >90%
- [ ] one atomic commit per AGENTS.md rule 8 (`--no-gpg-sign`)
@@ -0,0 +1,21 @@
# Task 01 — the instruction nudge (varied openers, marker untouched)
**Phase:** `155_folder_summary_voice` · **Story:** n/a
## Objective
`lite` stops opening every folder summary with "This folder contains…" — the instruction gains the variety direction while the E2E mock's keys (the `FOLDER_SUMMARY_MODE` marker + the `FOLDER_HEADER_PREFIX`) stay byte-identical.
## Work
1. `app/rag/folder_summaries.py` — `FOLDER_SUMMARY_INSTRUCTION` (L102–108): keep the existing four constraints (1–3 sentence; plain text; no markdown; no invention) and ADD the variety direction (the phase design's A1 wording — the two forbidden openers are the pin): "Open in a varied, natural way — state what the folder is for, the way a person would describe it. Do NOT begin with 'This folder contains' or 'The <folder name> folder contains'." (grammar may be polished; the two forbidden openers and the "varied, natural way" direction must survive).
2. `FOLDER_SUMMARY_MODE` (L91) and `FOLDER_HEADER_PREFIX` (`"Folder: "`) — UNTOUCHED (byte-identical — the mock's keys; a unit pin asserts it).
3. `app/rag/folder_summaries.py` module docstring — where the instruction's contract is described, name the nudge (cite the phase, don't restate the whole contract).
4. `tests/e2e/mock_llm.py` — the CANNED folder-summary reply (the pure function of the request): update its text to the new voice (a natural opener naming the folder — e.g. "Documentation for <folder> …" — NEVER the two forbidden openers). The marker + header keys are untouched; the reply stays a pure function of the request (the same inputs → the same output — the determinism contract).
## Testing & Quality
- Unit: the instruction pin (the two forbidden openers present as the constraint; the four preserved constraint words present); the marker + header prefix byte-identical pins; the mock's canned-reply shape (a folder's reply names the folder, avoids the forbidden openers, is a pure function of the request — two identical requests → identical replies).
- Coverage: **>90%** on the modified module (the pins exercise the changed constant).
## Completion Criteria
- [ ] the marker + header prefix pins are byte-identical to pre-phase
- [ ] the mock's canned folder reply avoids the forbidden openers and stays deterministic
- [ ] `uv run pytest tests/unit -q` green; `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,28 @@
# Task 02 — `scripts/regen_folder_summaries.py` (one-shot full regeneration)
**Phase:** `155_folder_summary_voice` · **Story:** n/a
## Objective
A standalone maintenance entrypoint refreshes EVERY stored folder summary against the (new) instruction in one `uv run` — the live catalogue's 15+ source-root rows all currently carry the formulaic opener, and the sync path's change-gated mode (phase 127) never re-touches unchanged folders, so a one-shot is the only way to re-voice the whole catalogue.
## Work
1. `scripts/regen_folder_summaries.py` — the `scripts/import_docs.py` entrypoint idiom (READ that file first and mirror its exact setup — do not invent a second pattern):
- `build_parser()` (argparse) — `--limit N` (the debug passthrough, same meaning as import_docs': cap the walk/generation; the summary line names it) if import_docs' parser has it — match import_docs' actual flags, do not guess;
- `main(argv: list[str] | None = None) -> int` + the `if __name__ == "__main__": raise SystemExit(main())` tail (match import_docs' tail);
- `get_settings()` + `configure_logging(settings.log_level)` + `configure_debugging()` (the phase-8 debug idiom — `DEBUGPY=1` honored, the AGENTS.md rule 7 contract);
- `llm = LLMClient()`; the short-lived DB session (the phase-53 convention — the script's own session commits; the generator only flushes — read import_docs' session handling and mirror it);
- the body: `stats = await generate_folder_summaries(session, llm)` — FULL mode (`changed_docs=None` is the default — do NOT pass it), `only_missing=False` (a REGEN — every stored row is refreshed, not just gaps; do NOT pass it either — the defaults), `skip=False` (do not pass);
- the cron-safe SINGLE summary line (the importer's idiom — one greppable line: `regen_folder_summaries: folders=<n> …` out of the returned stats dict) on the logger;
- exit 0 on success; non-zero + a named stderr line on failure (the fail-loud idiom — a missing admin secret / DB outage names the failure, never a silent partial).
2. NO new API surface; NO schema change; NO change to `app/api/sync.py` or the importer (phase 127's change-gated contract is untouched — the script is a consumer of the existing `changed_docs=None` full mode).
## Testing & Quality
- Unit: the script's wiring (a fake LLM + a fixture catalogue: `generate_folder_summaries` is called with the FULL-mode defaults — assert the call's kwargs; the stats dict surfaces in the summary line; a settings/boot failure exits non-zero with the named line — the app validator idiom).
- Integration: a full-mode regeneration over a fixture catalogue upserts EVERY candidate row (text + `updated_at` advance for all of them — a pure regen, no prune: the catalogue is unchanged, so the count-driven prune rule fires for nothing).
- Coverage: **>90%** on the script (the wiring pins exercise main()'s paths).
## Completion Criteria
- [ ] `uv run python -m scripts.regen_folder_summaries` runs the full regeneration against a fixture DB: every candidate row's `updated_at` advances, the single summary line logs, exit 0
- [ ] `DEBUGPY=1 uv run python -m scripts.regen_folder_summaries` honors the listener (the debugging idiom — the unit test for `configure_debugging`'s contract, the phase-8 precedent)
- [ ] `app/api/sync.py` + `app/rag/importer.py` are untouched (`git diff --name-only` scoped check)
- [ ] `uv run pytest -q` green (the script's pins); `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,22 @@
# Task 03 — unit pins: the instruction, the mock contract, the script wiring
**Phase:** `155_folder_summary_voice` · **Story:** n/a
## Objective
The load-bearing pieces are pinned: the instruction carries the two forbidden openers as its constraint (the four old constraints preserved), the mock's keys are byte-identical, the mock's canned reply is deterministic and opener-free, and the script calls the generator in full mode.
## Work
1. `tests/unit/` — the folder-summaries pin suite (the phase-94/96/127 suites are the precedent — locate `test_folder_summaries.py` or its equivalent and extend it):
- `FOLDER_SUMMARY_INSTRUCTION`: the two forbidden openers present as the constraint; the four preserved constraint words (1–3 sentence; plain text; no markdown; no invention) present;
- `FOLDER_SUMMARY_MODE` + `FOLDER_HEADER_PREFIX`: byte-identical to the pre-phase values (pin the verbatim strings — the mock contract);
- the mock's canned folder reply (in `test_mock_llm.py` or the mock's own suite — the phase's idiom): names the folder, avoids the two forbidden openers, is a pure function of the request (two identical requests → identical replies; different folders → different replies keyed on the header line).
2. The script's wiring pins (task 02's list — a new test module beside the import_docs' script pins, mirroring that idiom).
## Testing & Quality
- Unit: the pins above; every pre-existing pin stays green (additions only).
- Coverage: the pins exercise the changed constant + the script's paths.
## Completion Criteria
- [ ] `uv run pytest tests/unit -q` green — the new pins + every pre-existing pin
- [ ] the marker/header pins assert the verbatim pre-phase strings
- [ ] `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,21 @@
# Task 04 — the dedicated Playwright suite: the new voice, end to end
**Phase:** `155_folder_summary_voice` · **Story:** n/a (a phase-specific suite — `tests/e2e/test_folder_summary_voice.py`)
## Objective
An isolated E2E proves the voice change end to end: a KB change triggers the folder regeneration under the mock LLM, the STORED summary carries the mock's new-voice canned text (no forbidden opener), and the RAG catalogue renders it.
## Work
1. `tests/e2e/test_folder_summary_voice.py` — the dedicated file (run in isolation per AGENTS.md rules 4/9; the fixture idiom from the phase-127/94 suites — read `test_incremental_sync.py` / the folder-summary E2E for the boot + the KB-change fixture, mirror it, do not invent a new one):
- **test_regen_summary_avoids_forbidden_openers:** the fixture KB change (a new document under a fixture source folder — the suites' idiom) triggers the sync's folder regeneration (the change-gated path, phase 127) → the stored `folder_summaries` row for that folder equals the mock's canned reply for that folder (a pure function — assert the EXACT canned string) AND does not start with "This folder contains" / "The <folder> folder contains" (the shape pin);
- **test_rag_table_renders_the_stored_voice:** the RAG view's folder table (the phase-97 catalogue) shows the stored text in the DESCRIPTION cell for the regenerated folder (the text is user-derived — `textContent` only, the house rule — assert the visible cell text equals the canned reply, truncated display aside: the cell's `title`/full text attribute carries the full string if the display ellipsizes — check the kb-table cell's idiom in the phase-97 suite and assert accordingly);
- **test_unchanged_folders_keep_their_rows (phase-127 regression guard):** a KB change in folder A does not touch folder B's stored row (text + `updated_at` unchanged — the phase-127 contract; assert via the API/DB the way the phase-127 suite does).
2. The suite is DETERMINISTIC: mock-LLM only (the canned reply is a pure function — no `E2E_REAL_LLM` dependency).
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_folder_summary_voice.py -v --no-cov` in isolation (DB up: `podman compose up -d db`).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_folder_summary_voice.py -v --no-cov` green in isolation
- [ ] the stored-text assertion is an exact match against the mock's canned reply (deterministic)
- [ ] the phase-127 regression guard is asserted (unchanged folders keep their rows)
@@ -0,0 +1,43 @@
# Phase 156 — tables: sticky headers + ellipsis on clipped labels
**Source:** owner UX audit 2026-10-03 (live interactive-browser pass): "sticky table headers (RAG/Sources/History — the tables are long; the header scrolls away)" + "At ~1,009px window width the rightmost table columns start clipped with no ellipsis hint on the 'Hidden'/'Edit' labels (the wrapper scrolls fine — just subtle)".
**Story:** n/a (audit-derived phase; the table machinery is phase 97's `97_kb_tree_catalog` + phase 86's `86_history_page_width`, both complete)
**Context:** `styles.css:: .table-wrap` (L2528–2534: `position: relative; background; border; radius; box-shadow; overflow-x: auto`) wraps `.docs-table` (`width: 100%; min-width: 640px`) — the RAG folder table (the `kb-folders-table` class), the Sources table (`#git-sources-table-wrap`, L3390), and the History table (the phase-50/86 width contract, L5583). The thead already has a TINTED background (L530 comment: "the thead tinted") — the sticky design needs it OPAQUE (rows must not show through on scroll). The clipped cells: the Sources table's `Hidden` checkbox label + `Ignore paths`/`Remove` buttons, the RAG table's `Edit` button, the History table's `Copy`/`Unshare`/`Create link`/`Delete` buttons — today a clipped label hard-cuts (no `text-overflow: ellipsis`). **The sticky constraint (the design's load-bearing fact):** a `position: sticky` `th` sticks to its NEAREST SCROLLING ANCESTOR — with `overflow-x: auto`, the wrapper's computed `overflow-y` is `auto` too, so the wrapper IS the scroll container; but it has no height bound, so it never scrolls vertically — the PAGE does. A sticky header in an unbounded wrapper therefore scrolls away with the page. The header sticks only if the wrapper itself is the vertical scroll region (a bounded `max-height`) — the standard scrollable-table-with-sticky-header pattern. That is the UX trade this phase makes (ASSUMPTION A1): the tables become bounded internal-scroll panels; the page no longer carries their height.
## Objective
On the three catalogue tables (RAG folders, Sources, History): the header row stays pinned while the user scrolls the table's own (now bounded) vertical scroll, and a clipped cell label shows `…` instead of a hard cut — the full text remains reachable (title/aria, the established idiom).
## Dependencies
- `97_kb_tree_catalog` (complete) — the RAG folder table + the `.table-wrap` card.
- `86_history_page_width` (complete) — the History table's width contract (the sticky design must keep it — the table stays full-width inside the bounded panel).
- `63_unambiguous_document_listing` (complete) — the Sources table's column idiom (the `Hidden` label + per-row actions).
## Design (shared by all tasks — the executor reads this, not the chat)
- **A1 (owner-locked):** the bounded-panel design — `.table-wrap` gains `max-height` (ASSUMPTION A2: `min(70vh, 64rem)` — the phase picks this; it must keep a 15-row table scrolling while a 3-row one fits without a scrollbar: the `min()` of a viewport fraction and a fixed cap does exactly that) + `overflow: auto` (superseding `overflow-x: auto` — the horizontal scroll contract is UNCHANGED: the table's `min-width: 640px` still triggers the wrapper's horizontal scroll at narrow widths; `overflow: auto` keeps both axes). The `th` sticky: `position: sticky; top: 0; z-index: 1;` on `.table-wrap table thead th` with an OPAQUE background (the existing tint made opaque — `background: var(--surface)` or the existing tint token, whichever the L530 comment names; check the current value and make it non-transparent; do NOT add a new palette variable — B3: the 17-variable contract is untouched, and an all-default theme stays byte-identical in the served CSS — the rule changes apply to BOTH themes equally).
- **A3 (owner-locked):** the ellipsis hint — the clipped cell LABELS get `max-width` + `overflow: hidden; text-overflow: ellipsis; white-space: nowrap;` (a `.cell-clip` class or a direct selector on the action-cell text nodes — the executor's choice; the `docs-table`'s existing cell padding is untouched). Coverage: the Sources `Hidden` checkbox label + `Ignore paths` + `Remove`; the RAG `Edit`; the History action buttons (`Copy`/`Unshare`/`Create link`/`Delete`). The FULL text stays reachable: every clipped label's control already carries its accessible name (the `Remove` button's `aria-label` names the full source — the phase-89 idiom; CHECK each of the other controls and add a `title`/`aria-label` where one is missing — the audit: the RAG `Edit` button, the History action buttons, the `Ignore paths` button — add the missing labels in the same task).
- **Scope guard:** the CHAT view's `.md-table-wrap` (L539 — the markdown-rendered tables) is a DIFFERENT class — UNTOUCHED (it has no card, no height bound; the design is for the catalogue tables only). The mobile ≤640px contract (PLAN §7.1) is re-verified in the E2E (the hamburger nav + the tables' horizontal scroll at ≤640px still work — the `max-height` must not break the mobile scroll model).
## Tasks
1. `01_sticky_header.md` — the bounded-panel `max-height`/`overflow: auto` + the opaque sticky `th` (all three catalogue tables share the class — one rule).
2. `02_ellipsis_hints.md` — the clipped-label ellipsis + the missing title/aria audit (the five control families).
3. `03_unit_pins.md` — the CSS contract pins (the existing styles-pinning idiom — the `:root`-drift test parses `styles.css`; extend the same suite).
4. `04_e2e_table_polish.md` — the dedicated Playwright suite (narrow viewport: the sticky offset + the computed ellipsis style + the mobile contract).
## Testing & Quality
- Unit: the CSS pins — `.table-wrap` carries `max-height: min(70vh, 64rem)` + `overflow: auto`; `.table-wrap table thead th` carries `position: sticky; top: 0; z-index: 1` + an opaque `background` (parse the stylesheet — the styles-pinning idiom, the `:root`-drift test's precedent); the `.md-table-wrap` rule is byte-UNCHANGED (the scope guard).
- Integration: none (CSS-only phase — the server pins stay byte-identical as regression guards).
- Coverage: **>90%** on modified code (CSS pinned by the source-level suite + the E2E).
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's).
- E2E: `uv run pytest tests/e2e/test_table_ux_polish.py -v --no-cov` in isolation (DB up: `podman compose up -d db`).
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by the phase**.
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are user-visible (the bounded-panel scroll is a visible UX change — name it where the pages' layout is described, if anywhere).
## Completion Criteria
- [ ] all three catalogue tables: after scrolling the wrapper's vertical scroll, the `thead` stays at the panel top (the E2E asserts the `th`'s offsetTop ≈ 0 while the wrapper's scrollTop > 0)
- [ ] the horizontal-scroll contract is unchanged (narrow viewport: the table's `min-width: 640px` still scrolls inside the wrapper — the E2E)
- [ ] a clipped action label shows `text-overflow: ellipsis` (the E2E asserts the COMPUTED style on a forced-narrow cell — deterministic, no pixel assertions) and its full text is reachable (title/aria present on every clipped control)
- [ ] the mobile ≤640px contract holds (the hamburger nav + the table scroll — the E2E)
- [ ] `uv run pytest` green; the dedicated E2E green in isolation; `uv run ruff check . && uv run pyright` clean; `app/` coverage >90%
- [ ] one atomic commit per AGENTS.md rule 8 (`--no-gpg-sign`)
@@ -0,0 +1,22 @@
# Task 01 — the bounded-panel sticky header (one rule, three tables)
**Phase:** `156_table_ux_polish` · **Story:** n/a
## Objective
The catalogue tables' header rows stay pinned while the table's own vertical scroll moves — the standard scrollable-table-with-sticky-header pattern, shared by all three tables through the one `.table-wrap` class.
## Work
1. `frontend/assets/styles.css`:
- `.table-wrap` (L2528–2534): `overflow-x: auto` → `overflow: auto` (both axes — the horizontal contract via the table's `min-width: 640px` is unchanged) + `max-height: min(70vh, 64rem)` (the phase design's A2 value). Keep `position: relative` (the sticky's containing context) — the rest of the block (background/border/radius/shadow) is untouched.
- `#git-sources-table-wrap` (L3390) — check whether it OVERRIDES the base `.table-wrap` (a height/overflow of its own): if it does, harmonize it (the same `max-height`/`overflow: auto`; do not leave a divergent rule that would make the Sources table unbounded while the others are bounded — the one-rule contract).
- the sticky rule: `.table-wrap table thead th { position: sticky; top: 0; z-index: 1; }` + an OPAQUE background. Check the existing thead background (L530 comment: "the thead tinted" — the `.docs-table thead th` rule at L542 is the MARKDOWN table (`md-table-wrap`), a DIFFERENT class — the catalogue tables' thead background is set elsewhere; locate the catalogue thead's current background and make it opaque (the tint token as-is if it is already opaque; otherwise the surface token). NO new palette variable (B3); the rule applies equally under a themed page (B4 — a themed page gets the same sticky behavior, the `#bor-theme` override of the background token handles the color).
- the `.md-table-wrap` block (L539) is UNTOUCHED (the scope guard — a pin asserts it).
2. Comment the rule in the file's established voice (the phase-97/86/50 comment blocks that describe the scrollable `.table-wrap` — name the sticky + the bounded panel, cite the phase, don't restate the whole contract).
## Testing & Quality
- Unit: the CSS pins (task 03's suite) — the `max-height` value, `overflow: auto`, the sticky th rule, the opaque background, the `.md-table-wrap` byte-unchanged.
- Coverage: **>90%** (CSS pinned by the source-level suite).
## Completion Criteria
- [ ] the CSS pins pass; the `#git-sources-table-wrap` override is harmonized (no divergent unbounded rule)
- [ ] `uv run pytest tests/unit -q` green; `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,24 @@
# Task 02 — ellipsis on clipped labels + the title/aria audit
**Phase:** `156_table_ux_polish` · **Story:** n/a
## Objective
A clipped cell label shows `…` instead of a hard cut, and every clipped label's full text stays reachable (title/aria) — the five control families across the three catalogue tables.
## Work
1. `frontend/assets/styles.css` — the ellipsis rule (the phase design's A3): the clipped cell LABELS get `max-width` + `overflow: hidden; text-overflow: ellipsis; white-space: nowrap;`. The executor's choice of selector shape (a `.cell-clip` class added to the label nodes, or direct selectors) — the constraint: the `docs-table` cell padding is untouched, and a cell that FITS shows its full text (the `max-width` must be generous enough that `Edit`/`Hidden`/`Remove` at full width are never clipped — the ellipsis is for the CLIPPED case only; the `max-width` is on the label span, not the cell).
2. The label nodes (markup — `frontend/index.html` + `frontend/assets/git-sources.js` + `frontend/assets/history.js` + `frontend/assets/sources.js` — the tables' renderers build their cells with the DOM APIs, the house rule): wrap the clipped labels in the `.cell-clip` span (or the chosen selector's class) — the five families:
- the Sources table: the `Hidden` checkbox LABEL (the visible text — the checkbox's own `aria-label` already names the full source, the phase-105 idiom), the `Ignore paths` button text, the `Remove` button text (its `aria-label` names the full source — KEEP it; the visible text clips);
- the RAG folder table: the `Edit` button text;
- the History table: the `Copy`/`Unshare`/`Create link`/`Delete` button texts (the `Delete` button's `aria-label` already names the chat title, the phase-66 idiom — CHECK the other three and add the missing `aria-label`s: `Copy the share link for <title>` / `Unshare <title>` / `Create a share link for <title>` — the audit's additions).
3. The missing title/aria (the audit, one task): for every clipped control that has NO accessible name beyond the visible text, add the `aria-label`/`title` — the `Remove` (Sources) and `Delete` (History) precedents are the idiom; the additions must name the ROW's identity (the source URL / the chat title — the values the renderers already have in hand).
4. Comment the change in the file's established voice (cite the phase; the B5 "text + color, never color alone" idiom is already satisfied — the labels are text).
## Testing & Quality
- Unit: the source-level pins — the renderers' cell builders carry the `.cell-clip` span (or the chosen class) on the five label families; the added `aria-label`s are present on the audited controls (assert the attributes the renderers set — the source-pinning idiom).
- Coverage: **>90%** on the modified JS/markup (the pins).
## Completion Criteria
- [ ] the five control families carry the ellipsis class (the pin) and the computed style yields `text-overflow: ellipsis` on a forced-narrow viewport (the E2E, task 04)
- [ ] every clipped control's full text is reachable: the audit's `aria-label` additions are present (the pin + the E2E)
- [ ] `uv run pytest tests/unit -q` green; `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,24 @@
# Task 03 — CSS contract pins (the styles-pinning idiom)
**Phase:** `156_table_ux_polish` · **Story:** n/a
## Objective
The design's load-bearing CSS facts are pinned at the source level — a later "cleanup" cannot silently unstick the header, unbound the panel, or transparent the thead.
## Work
1. `tests/unit/` — the styles-pinning suite (the `:root`-drift test that PARSES `styles.css` is the idiom — the phase-91/92 theme suites extended it; locate the stylesheet-parsing test module and extend it):
- `.table-wrap`: `max-height: min(70vh, 64rem)` (the exact value pinned) + `overflow: auto`;
- `.table-wrap table thead th`: `position: sticky` + `top: 0` + `z-index: 1` + an OPAQUE `background` (the parse asserts the background is a non-`transparent` token — the exact token is the phase's choice, the OPACITY is the pin);
- `#git-sources-table-wrap`: no divergent unbounded override (if the rule exists, it carries the same `max-height`/`overflow: auto` — the pin asserts harmony);
- `.md-table-wrap`: byte-UNCHANGED (the scope guard — pin the rule's exact pre-phase text);
- the ellipsis rule: the clipped-label selector carries `text-overflow: ellipsis` + `overflow: hidden` + `white-space: nowrap`.
2. The `:root`-drift test itself is UNCHANGED (the theme contract is untouched — this phase adds no palette variable; a pin asserts the 17-variable set is byte-identical).
## Testing & Quality
- Unit: the pins above; every pre-existing pin stays green (additions only).
- Coverage: the pins exercise the changed CSS.
## Completion Criteria
- [ ] `uv run pytest tests/unit -q` green — the new pins + every pre-existing pin
- [ ] the `.md-table-wrap` pin asserts the verbatim pre-phase rule
- [ ] `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,23 @@
# Task 04 — the dedicated Playwright suite: sticky + ellipsis + mobile
**Phase:** `156_table_ux_polish` · **Story:** n/a (a phase-specific suite — `tests/e2e/test_table_ux_polish.py`)
## Objective
An isolated E2E proves the visible contracts on the real DOM: the header stays pinned while the table's own scroll moves, a forced-narrow viewport shows the ellipsis computed style on a clipped label with its full text reachable, and the mobile ≤640px contract (hamburger nav + horizontal table scroll) survives the bounded panel.
## Work
1. `tests/e2e/test_table_ux_polish.py` — the dedicated file (run in isolation per AGENTS.md rules 4/9; the fixture idiom from the phase-97/86 suites — read one of their E2E files for the boot + the table fixtures, mirror it, do not invent a new one):
- **test_sticky_header_on_vertical_scroll (×3 tables, parameterized — RAG folders / Sources / History):** a tall fixture (the suites' idiom — enough rows to exceed the panel's `max-height`); scroll the wrapper (`el.scrollTop = <a large value>`); assert `wrapper.scrollTop > 0` AND the `thead th`'s `offsetTop ≈ 0` relative to the panel (the offset assertion — the Playwright `boundingBox` delta, the standard sticky assertion; NO pixel-tolerance games: the sticky offset is exactly 0 while scrolling).
- **test_horizontal_scroll_contract_unchanged:** a narrow viewport (the E2E browser resizes — the suite's idiom) → the table's `min-width: 640px` still scrolls horizontally inside the wrapper (`wrapper.scrollWidth > wrapper.clientWidth` with a table wider than the panel) — the phase-97/86 contract.
- **test_ellipsis_on_clipped_label:** force the action column's clip (a narrow viewport that clips the rightmost cells — the audit's ~1,009px case, or a tighter fixture width): assert the clipped label's COMPUTED style `text-overflow === "ellipsis"` (deterministic — the computed style, not a pixel), AND `el.scrollWidth > el.clientWidth` (the label is actually clipped), AND its full text is reachable: the control's `aria-label`/`title` (the audit's additions) contains the row identity (the source URL / the chat title).
- **test_mobile_contract:** ≤640px viewport (the PLAN §7.1 contract — the phase-88 suite's idiom): the hamburger nav opens + the catalogue table's horizontal scroll works inside the bounded panel + the `thead` still pins (the mobile regression guard — the `max-height` must not break the mobile scroll model).
2. The suite is DETERMINISTIC: fixed fixture row counts (the suites' idiom), computed-style assertions (never pixel/offset tolerance beyond the exact-0 sticky offset), no real-LLM dependency.
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_table_ux_polish.py -v --no-cov` in isolation (DB up: `podman compose up -d db`).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_table_ux_polish.py -v --no-cov` green in isolation
- [ ] the sticky assertion is the exact-0 offset while `scrollTop > 0` (all three tables)
- [ ] the ellipsis assertion is the computed style + the actual-clip check + the reachable full text
- [ ] the mobile contract test passes (the phase-88 idiom)
@@ -0,0 +1,41 @@
# Phase 157 — hero suggestion chips cap their display at 90 characters
**Source:** owner UX audit 2026-10-03 (live interactive-browser pass): "hero suggestion chips — consider a char cap — long opening questions get hard-truncated mid-word… the chips stretch near full container width before clipping."
**Story:** n/a (audit-derived phase; the chip machinery is phase 80's `80_history_suggestion_chips` + phase 104's `104_chip_sizing_question_cap`, both complete)
**Context:** `app.js::renderChips(container, items, onSelect)` (L1272–1315) — the ONE shared chip component (the hero `#suggestions` + the deflection "Maybe try" chips). Phase 104's contract (owner-locked 2026-09-12, in-code): the single-line chip CLIPS long questions (CSS), `title` is the hover reveal, `aria-label` is the clipped-case accessible name (the source-chip pattern), and the click submits the FULL `text` (`submitSuggestion(text)` + `onSelect(text, btn)`). Today there is NO display-length cap — a 300-char opening question stretches its chip to near full container width before the CSS clip engages (the audit's observation: the hero's third chip ran the full reading-column width). Phase 104 also capped the COMPOSER (the input) — a different surface, untouched here. The chips' content is the 3 newest saved chats' opening questions (phase 80/103) — owner-derived, `textContent`-only (the house rule), so a display cap is a pure presentation change: the click payload stays full.
## Objective
The chip's DISPLAYED text caps at 90 characters with a clean `…` (the chip stays compact — no more full-width hero chips), while the `title` hover reveal, the clipped-case `aria-label`, and the click payload all keep the FULL question (phase 104's contract, extended).
## Dependencies
- `80_history_suggestion_chips` (complete) — the hero chips' content contract (the 3 newest opening questions).
- `104_chip_sizing_question_cap` (complete) — the single-line CSS clip + the `title`/`aria-label` idiom this phase EXTENDS (the cap makes the CSS clip a secondary guard, not the primary one).
## Design (shared by all tasks — the executor reads this, not the chat)
- **The cap (ASSUMPTION A1, owner-locked):** `CHIP_CAP = 90` — a named constant in `app.js` (the module's top, beside the other chip constants). Applied to the DISPLAYED text ONLY in `renderChips`: `btn.textContent = text.length > CHIP_CAP ? text.slice(0, CHIP_CAP).trimEnd() + "…" : text`. `slice(0, 90).trimEnd()` avoids the trailing-space-before-`…` artifact when char 90 is mid-word (the `trimEnd` is the pin). The `title` is UNCHANGED (the full `text` — the hover reveal, phase 104). The `aria-label` logic is UNCHANGED (the clipped-case accessible name is the full `text` — with the cap, a long chip is ALWAYS "clipped" in the visual sense, but phase 104's `scrollWidth > clientWidth` check still governs the aria-label: a 91-char display still overflows its single-line box at typical widths, so the aria-label lands; a SHORT string (< 90) is byte-identical to pre-phase in every respect — the no-cap case is unchanged).
- **The click payload (the load-bearing invariant):** `submitSuggestion(text)` + `onSelect(text, btn)` take the FULL `text` (the un-capped value) — UNCHANGED. The cap is display-only: a capped chip sends the full question (the user gets their full question submitted — the phase-80 contract). A unit pin asserts the handler receives the FULL string for a >90-char item.
- **Scope:** `renderChips` is shared (hero + Maybe-try) — the cap applies to both (a single code path; the Maybe-try chips are title-derived and short — the cap is a no-op there, and the byte-identity of short strings is the guard). NO other file changes (the CSS clip stays as the secondary guard; the composer's phase-104 cap is a different surface — untouched).
## Tasks
1. `01_chip_cap.md` — the `CHIP_CAP` constant + the display-only cap in `renderChips` + the comment in the phase-104 voice.
2. `02_unit_pins.md` — the boundary pins (89/90/91), the full-text invariant, the short-string byte-identity.
3. `03_e2e_chip_cap.md` — the dedicated Playwright suite.
## Testing & Quality
- Unit: source-level pins — the 90-char boundary (an 89-char display is unchanged; a 90-char display is unchanged (the cap is `>`); a 91-char display is `slice(0,90).trimEnd() + "…"` — the exact string pinned); the `title` is the full text in all three cases; the click/`onSelect` payload is the FULL `text` for a >90-char item (the load-bearing invariant); a <90-char item is byte-identical to pre-phase in textContent/title/aria-label (the short-string guard).
- Integration: the `/api/suggestions` contract is untouched (no server change — a regression guard only).
- Coverage: **>90%** on the modified JS (the pins).
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's).
- E2E: `uv run pytest tests/e2e/test_chat_chip_cap.py -v --no-cov` in isolation (DB up: `podman compose up -d db`; the deterministic `tests/e2e/mock_llm.py`).
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by the phase**.
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are user-visible (the cap is a visible UI change — name it where the onboarding chips are described, if anywhere).
## Completion Criteria
- [ ] a >90-char opening question renders a ≤91-char display (the cap + the `…`) — the E2E
- [ ] clicking the capped chip submits the FULL question (the user bubble shows the full text — the E2E's load-bearing assertion)
- [ ] the `title` hover reveal + the clipped-case `aria-label` carry the full text (the E2E)
- [ ] a <90-char question is byte-identical to pre-phase (the pin)
- [ ] `uv run pytest` green; the dedicated E2E green in isolation; `uv run ruff check . && uv run pyright` clean; `app/` coverage >90%
- [ ] one atomic commit per AGENTS.md rule 8 (`--no-gpg-sign`)
@@ -0,0 +1,22 @@
# Task 01 — the `CHIP_CAP` display cap in `renderChips`
**Phase:** `157_chat_chip_cap` · **Story:** n/a
## Objective
The shared chip component caps its displayed text at 90 characters with a clean `…` — a long opening question no longer stretches its chip to full container width — while the `title`, the `aria-label`, and the click payload keep the full text.
## Work
1. `frontend/assets/app.js`:
- `const CHIP_CAP = 90;` — the named constant at the module top, beside the other chip constants (the module's established placement — the `suggestions` section header, L1272, is the anchor; place it just above `renderChips`).
- `renderChips` (L1296–1315): the display line becomes the cap (the phase design's A1 shape): `btn.textContent = text.length > CHIP_CAP ? text.slice(0, CHIP_CAP).trimEnd() + "…" : text;` — the `title` line (`btn.title = text`) is UNCHANGED (the full text — the hover reveal, phase 104); the `aria-label` logic (L1314: the `scrollWidth > clientWidth` check → `aria-label = text`) is UNCHANGED; the click handler (`submitSuggestion(text)` + `onSelect(text, btn)`) is UNCHANGED (the full `text` — the load-bearing invariant).
- the phase-104 comment block (L1298–1301, "the single-line chip clips long questions — `title` is the hover reveal, `aria-label` the clipped-case accessible name") gains the cap sentence in the same voice (name the phase-157 cap as the PRIMARY guard, the CSS clip as the secondary — cite, don't restate the whole contract).
2. NO other file changes: the CSS clip rule stays (the secondary guard); the composer's phase-104 cap is a different surface (untouched); the "Maybe try" path reuses `renderChips` automatically (a single code path).
## Testing & Quality
- Unit: task 02's pins.
- Coverage: **>90%** on the modified JS (the pins).
## Completion Criteria
- [ ] the constant + the cap line land exactly as designed (the source pin)
- [ ] the `title` / `aria-label` / click-handler lines are byte-unchanged (a `git diff` scoped check)
- [ ] `uv run pytest tests/unit -q` green; `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,23 @@
# Task 02 — unit pins: the 90-char boundary, the full-text invariant, the short-string guard
**Phase:** `157_chat_chip_cap` · **Story:** n/a
## Objective
The cap's three contracts are pinned at the source level: the 90-char boundary (display), the full-text invariant (title/aria/click), and the short-string byte-identity (the no-cap case).
## Work
1. `tests/unit/` — the `app.js` source-level pin suite (the phase-80/104 suites are the precedent — locate the chip pin module and extend it):
- **the boundary:** an 89-char item → `textContent` byte-identical to the input (no cap); a 90-char item → byte-identical (the cap is `>`, not `≥`); a 91-char item → `slice(0, 90).trimEnd() + "…"` — the EXACT string pinned (pin a 91-char fixture where char 90 is a letter AND one where it is a space — the `trimEnd` case is the artifact the cap avoids);
- **the full-text invariant:** for a >90-char item: `title` === the full text; the click handler's payload (the `onSelect` spy) === the full text; `submitSuggestion`'s argument === the full text (the load-bearing assertion — a capped chip sends the FULL question);
- **the short-string guard:** a <90-char item → `textContent`/`title` byte-identical to pre-phase + the `aria-label` logic unchanged (the `scrollWidth > clientWidth` check — the DOM-simulation idiom the phase-104 suite uses);
- **the Maybe-try path (the shared-component guard):** the cap applies identically through `appendMaybeTry` (a >90-char suggestion there renders capped — a single code path; a short one is byte-identical).
2. The `/api/suggestions` payload pin (if the suite pins it — the phase-80 idiom): UNCHANGED (no server change in this phase).
## Testing & Quality
- Unit: the pins above; every pre-existing pin stays green (additions only).
- Coverage: the pins exercise the changed branch.
## Completion Criteria
- [ ] `uv run pytest tests/unit -q` green — the new pins + every pre-existing pin
- [ ] the full-text invariant pin asserts the full string (not a length) in `title`, `onSelect`, and `submitSuggestion`
- [ ] `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,23 @@
# Task 03 — the dedicated Playwright suite: the capped chip, the full submit
**Phase:** `157_chat_chip_cap` · **Story:** n/a (a phase-specific suite — `tests/e2e/test_chat_chip_cap.py`)
## Objective
An isolated E2E proves the user-facing contract: a >90-char opening question renders a compact capped chip (display ≤ 91 chars ending in `…`), the `title` hover reveal + the clipped-case `aria-label` carry the full text, and clicking the capped chip submits the FULL question (the user bubble shows the full text).
## Work
1. `tests/e2e/test_chat_chip_cap.py` — the dedicated file (run in isolation per AGENTS.md rules 4/9; the fixture idiom from the phase-80 suite — read `test_suggestion_chips.py` (or its actual name in `tests/e2e/`) for the saved-chat-seeding idiom, mirror it, do not invent a new one):
- **the seed:** a saved chat whose opening question is a >90-char string (the suite builds it — a fixed >90-char question, e.g. 120 chars, so the cap engages deterministically; the saved-chat API the phase-80 suite uses is the seeding idiom) → the hero renders the 3-chip contract (phase 80/103) with the seeded question as a chip.
- **test_capped_display:** the chip's `textContent` length ≤ 91 AND ends with `…` AND equals the pinned `slice(0, 90).trimEnd() + "…"` of the seeded question (the exact-string pin — the E2E asserts the byte-for-byte display, not just a length);
- **test_full_text_reachable:** the chip's `title` attribute === the full seeded question; the chip's `aria-label` (the clipped case — the 91-char display overflows its single-line box at the E2E viewport) === the full seeded question (the phase-104 idiom);
- **test_click_submits_full (the load-bearing assertion):** the chip is clicked → the mock-LLM turn runs → the USER bubble's text === the FULL seeded question (not the capped display — the phase-80 contract: the chip sends the full question; the cap is display-only);
- **test_short_chip_unchanged (the regression guard):** a seeded <90-char opening question renders byte-identical to pre-phase (the chip's `textContent` === the question, no `…` appended) — the short-string guard on the live DOM.
2. The suite is DETERMINISTIC: the mock-LLM answer (no `E2E_REAL_LLM` dependency); the seeded questions are fixed strings (the pins, not generated lengths).
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_chat_chip_cap.py -v --no-cov` in isolation (DB up: `podman compose up -d db`).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_chat_chip_cap.py -v --no-cov` green in isolation
- [ ] the full-text assertion is byte-for-byte (the `title`, the `aria-label`, and the user bubble all equal the FULL seeded question)
- [ ] the short-chip guard passes (a <90-char question is byte-identical to pre-phase)
@@ -0,0 +1,427 @@
# Phase 158 — Chronological thinking / tool-call timeline
**Source:** owner report (live interactive-browser session, 2026-10-04, shared page
`https://brain.reeseapps.com/shared/95dd7a2a-8cda-478b-b694-53936070b9fb` — a real
`turbo` turn with 6 tool calls): "the thinking block happens first but LLM sent a
normal response, not a thinking response, and then went back to thinking. This is
awkward. Then the tool calling happens, and the thinking block reads strangely
because the tool calls are shown below the thinking block. … show the thinking /
tool call / responses in order, rather than in single big blocks each."
**Story:** n/a (owner bug report — live site, 2026-10-04)
**Context:** `app/rag/agent.py::run_agent` (yields `thinking` / `content` /
`ToolCallPiece` / `ToolResultPiece` in TRUE chronological order per round —
thinking → tool call → thinking → … → answer; the SSE mapping in
`app/api/chat.py` preserves that order, so NO backend stream change is needed);
`frontend/assets/app.js` — `ensureThinkingBlock` (~L1053, ONE `details.thinking`
per message, idempotent; phase 109 D15 re-opens it on every later thinking frame),
`closeThinkingBlock` (~L1075), `closeToolCalls` (~L1086), `appendToolLine`
(~L1136, ONE `details.tool-calls-disclosure` per message — phase 117 D3
open-live/fold-at-rest), `armToolLineClock` (~L1479, the phase-87 clock queries
`.tool-calls .tool-call:last-child`), the turn handler in `runTurn`
(thinking ~L3148 / tool ~L3193 / delta ~L3300 / done ~L3313 branches;
`thinkingAcc` ~L3071, `toolAcc` ~L3080, `segRuns` — NEW — module scope beside
them; the done save point ~L3355, the `tool_result` branch ~L3389, the
stream-drop `finalizeFailedTurn` call ~L3417), `rememberBrainTurn` (L2589,
`meta` spread — the pagehide partial save reuses it), `finalizeFailedTurn`
(L2983); `renderStoredMessage` (~L2089: the `m.thinking` block + the `m.tools`
loop, both rendered collapsed); `frontend/assets/shared.js` — `addThinkingBlock`
(L126) + `addToolLines` (L175, the byte-parity copy, rendered closed);
`app/schemas.py` — `ChatMessage` (L1103, `extra="forbid"`, phase-83 value caps,
the wrap serializer that omits absent image keys), `ToolCall` (L1017),
`HistoryTurn` (L58 — the history wire, UNCHANGED); `tests/e2e/mock_llm.py`
(`_tool_flow` L1820, `_tool_call_stream` L3813, `_sse_stream` L3727,
`compose_thinking` L3589, the trigger-dispatch order in `chat_completions`
L3872 — the more-specific-phrase-wins convention); the E2E suites whose pins
this phase updates (task 05): `test_turn_progress_loader.py` (the phase-109 D15
re-open pin), plus the unit source pins in `test_frontend_feedback.py`,
`test_frontend_turn_loader.py`, `test_frontend_tool_states.py`,
`test_chat_persistence.py`, `test_shared_page.py`, `test_tool_call_compact.py`,
`test_thinking_scroll.py`, `test_big_read_progress.py`, `test_read_range_tool_lines.py`,
`test_read_truncation_frontend.py`, `test_search_tool_scope.py`,
`test_frontend_failed_turn.py` (each pins `ensureThinkingBlock` / `appendToolLine`
/ the clock in different depths — inspect each; ONLY the pins the redesign breaks
change, the rest stay green untouched).
## Bug basis (code-traced, 2026-10-04)
The server streams the turn in true chronological order (`run_agent` yields per
round: thinking → tool call → thinking → … → answer), but the client LUMPS:
`app.js` funnels every thinking frame of the turn into ONE `details.thinking`
block (`ensureThinkingBlock` is idempotent — the phase-109 D15 re-open re-opens
that same block for each later agent round) and every tool frame into ONE
`details.tool-calls-disclosure`. DOM order is therefore always
`[ALL thinking] → [ALL tool calls] → [answer]` regardless of the actual order —
the owner's "thought everything, then called all the tools, then answered"
reading. The "thinking → answer → back to thinking" awkwardness is the D15
re-open live: the model emits a short mid-round reply alongside a tool call
(React style), the first `delta` closes the one thinking block and the answer
starts, then the next round's thinking frame re-opens the SAME block above the
tool lines — the answer begins, then thinking resumes on top of it. The persisted
`bor.chat.v1` record only stores `thinking` (one concatenated string) + `tools`
(one array) — the per-run order is never captured, so the shared page
(`shared.js`) renders the same lumped layout, collapsed.
## Objective
Render each agent turn as a chronological timeline — each CONTIGUOUS run of
thinking frames in its own `details.thinking` block, each CONTIGUOUS run of tool
calls in its own `details.tool-calls-disclosure` (`Tool call(s) (N)` per run),
segments in arrival order — live, on restore, and on the shared page; persist the
run order in a new optional `segments` array on the brain record (additive;
records without it — all pre-phase — restore byte-identically through the
untouched legacy path).
## Owner decisions (chat, 2026-10-04 — recorded per AGENTS.md rule 3)
- **D1 — Per-run timeline.** Each contiguous thinking run = its own Thinking
block; each contiguous tool-call run = its own disclosure. Consecutive frames
of the same kind extend the current run; the first frame of a new kind closes
the previous run and opens the new one.
- **D2 — Option B mid-round placement (owner-chosen, shown as a live DOM demo in
the owner's browser 2026-10-04).** ALL segments render ABOVE the answer
bubble, in arrival order — including runs that start AFTER the first `delta`
(the mid-round reply case). The bubble stays ONE element; its content
accumulates in arrival order (the mid-round text and the final answer are one
streamed text). Rejected: Option A (post-answer segments below the bubble —
the bubble grows above its own scratchpad).
- **D3 — Plain `Thinking` label on every block (owner-chosen).** The phase-17
pinned summary literal stays byte-identical across runs (zero new literals;
position distinguishes the runs). The disclosure summary stays the phase-117
`Tool call(s) (N)` — now per run.
- **D4 — Additive persistence.** The brain record gains an OPTIONAL `segments`
array; `thinking` (full concatenated text — feeds the history wire + the
per-turn log) and `tools` (all call records) are KEPT (old-client graceful
degradation; the phase-113 lesson applied up front). `segments` is omitted when
absent (the `undefined`-drops-the-key house pattern) — a no-thinking,
no-tools record stays byte-identical to pre-phase.
- **D5 — Open live, fold at rest** (extends phase 117 D3 + the phase-17
open-while-thinking contract): the active run's block is OPEN while its frames
stream; a new run's first frame closes the previous run; the first `delta`,
`done`, and the stop/abort settle fold the open runs; the restore path and
the shared page render everything COLLAPSED.
- **D6 — Server: a validated `segments` field on `ChatMessage`.**
`extra="forbid"` means the new key would 422 every saved-chat write without a
schema field (the phase-113 lesson: a missing key silently UNSAVED every
grounded brain record). The field is a discriminated union (`kind` =
`"thinking"` | `"tool"`) with phase-83-style value caps, omitted-when-`None`
in the serializer (the phase-50 round-trip contract). No migration (the record
is opaque JSONB), no SSE contract change, no history-wire change.
## Design (shared by all tasks — the executor reads this, not the chat)
### The `segments` entry shape (D4)
```
segments: [
{ kind: "thinking", text: "<that run's full text>" },
{ kind: "tool", name: "…", argument?: "…", offset?: int, limit?: int,
path?: "…", truncated?: true, chars_shown?: int, chars_total?: int },
…
]
```
- One `thinking` entry per RUN (the run's accumulated text — the same text that
streams live into that run's block). One `tool` entry per TOOL CALL (the exact
`toolAcc` record shape — the phase-37 `{name, argument}` plus the phase-139
`offset/limit` and phase-146 `path` additive fields, and the phase-95
`truncated/chars_shown/chars_total` stamp when present).
- Built at save time from the run tracker: an EMPTY thinking run stores NOTHING
(a zero-char run — e.g. a `BOR_STREAM_THINKING`-suppressed turn — is absent);
a turn with no thinking and no tools stores no `segments` key at all.
- `thinking` (the full concatenation, for the history wire + the log) and `tools`
(every call record, for old clients) are UNCHANGED in the record.
### `app/schemas.py` (task 01)
- `ChatThinkingSegment`: `kind: Literal["thinking"]`, `text: str`
(`min_length=1`, `max_length=32_000` — `HistoryTurn`'s cap, phase 83).
- `ChatToolSegment`: `kind: Literal["tool"]` + the EXACT `ToolCall` field set
(name/argument/offset/limit/path/truncated/chars_shown/chars_total with their
existing caps) — one model, no duplication of the field definitions where
practical (the docstring cross-references `ToolCall`).
- `ChatSegment = Annotated[Union[ChatThinkingSegment, ChatToolSegment],
Field(discriminator="kind")]`.
- `ChatMessage.segments: list[ChatSegment] | None = Field(default=None,
max_length=50)` (the phase-83 cap style — far past any realistic turn: the
round cap is 10 → ≤ 20 entries; 50 matches the `tools` cap).
- The EXISTING wrap serializer (`_drop_image_when_absent`) gains the rule:
`segments is None → pop "segments"` (ABSENT, never `null` — the phase-50
contract; a record WITH segments keeps them). `HistoryTurn` and the
`history_to_messages` mapper are UNTOUCHED (segments never travel the wire —
the concatenated `thinking` travels exactly as today).
### `frontend/assets/app.js` — the live turn (task 02)
- Turn state: `segRuns = []` (module scope, reset in `runTurn` beside
`thinkingAcc`/`toolAcc` — an array of run objects
`{ kind, text, records }`), `currentRun = null` (turn-local).
- **Insertion anchor (D2):** every run element is inserted `before`
`body.querySelector(".bubble")` (get-or-create the bubble first, as today).
There is no other anchor — segments ALWAYS sit above the bubble, in arrival
order. `ensureThinkingBlock`'s old `.tool-calls-disclosure ?? .tool-calls ??
.bubble` anchor is REPLACED by this.
- **`thinking` frame (per frame):** `settleToolLine()` (unchanged);
`thinkingAcc += ev.text` + `sawThinking` + `clearTurnTimeout()` + `if (!wrap)
wrap = addMessage("brain","")` + `removeTyping()` (all unchanged). Then the
run logic: if `currentRun?.kind === "thinking"` → extend it (re-render
`renderMarkdown(run.text)` into THAT block's `.thinking-text` — the existing
pre-update pin-state / bottom-follow logic moves verbatim into this branch,
now per-block); else → close `currentRun` (if any, `details.open = false`),
create `details.thinking` (`open = true`, `<summary>Thinking</summary>` +
`.thinking-text`, inserted before the bubble), push `{kind:"thinking",text:"",
records:[]}` to `segRuns`, `currentRun` = it, extend.
- **`tool` frame (per frame):** the existing guards stay byte-identical
(the phase-139 `frameOffset`/`frameLimit`, the phase-146 `framePath`
well-formedness; `toolAcc.push(toolRecord)`; `clearTurnTimeout()`; the
`toolStatus` label logic; the phase-70 legacy-name mapping). Then the run
logic: if `currentRun?.kind === "tool"` → extend that disclosure; else → close
`currentRun` (if any), create `details.tool-calls-disclosure` (`open = true`,
`summary.tool-calls-summary` + the `div.tool-calls` list `role=list`
`aria-label="Tool calls"`, inserted before the bubble), push
`{kind:"tool",text:"",records:[]}` to `segRuns`, `currentRun` = it; extend =
build the line EXACTLY as today (`appendToolLine` — now with the container
passed in: `appendToolLine(container, name, argument, offset, limit, scope)`,
the line bytes + the four pinned `line.textContent` label literals + the
phase-139 range suffix + the phase-146 scope span UNCHANGED, the summary
count updated per append — `Tool call(s) (N)` per run), then
`armToolLineClock(wrap, lineEl)` (the NEW signature — task 02's clock fix).
- **`armToolLineClock(wrap, lineEl)` (the phase-87 multi-disclosure fix):**
store the `lineEl` reference (module scope, reset with the wrap); the interval
appends the `.tool-elapsed` suffix to `lineEl` instead of querying
`.tool-calls .tool-call:last-child` (that query breaks with multiple
disclosures — it would find the last line of the FIRST list). The visible
behavior is identical (the suffix on the newest line; `settleToolLine` still
removes every `.tool-elapsed` in the wrap + resets the reference).
- **`delta` handler:** `settleToolLine()` + `acc += ev.text` + the
`thinking→streaming` transition UNCHANGED; replace
`closeThinkingBlock(wrap)` + `closeToolCalls(wrap)` with `closeCurrentRun()`
(folds `currentRun` if open; idempotent; no-op without a run).
- **The other settle-pair sites — `finalizeFailedTurn`'s `if (wrap)` block
(L2990–L2991), the STOP path (L3477–L3478), and every remaining
`closeThinkingBlock(wrap); closeToolCalls(wrap);` pair (grep — the
redesign removes both functions):** `closeAllRuns(wrap)` (closes EVERY
open `details.thinking` + `details.tool-calls-disclosure` in the wrap —
defensive; by construction at most the last run is open, but a late
double-close must be a no-op). The rest of `done` is unchanged.
- **Save payloads (the FIVE sites):** `buildSegments(segRuns)` (a helper —
maps runs: thinking run → `{kind:"thinking",text}` when non-empty; tool run
→ one `{kind:"tool",…record}` per record, in order) — the `done` save point
(~L3355); the `finalizeFailedTurn` parameter + its `if (wrap)` saved record
(the two call sites — stream-drop ~L3431, network-error ~L3519 — pass
`buildSegments(segRuns)`); the STOP path's save meta (L3487–L3491,
`stopped: true`); the pagehide partial (L3642–L3650 — `segRuns` is module
scope, readable there). All include `segments` non-empty only. The
zero-frame fallback save needs nothing (zero frames → no runs → no key).
The `thinking`/`tools` keys are UNCHANGED at every site.
- The UI state machine, `#send-status` copy, the 300 s pre-token guard, the
stream-drop guard, and the `thinking-without-answer` fallback are ALL
UNCHANGED (§7.4 never-stale intact). `BOR_STREAM_THINKING=0` needs no client
change (no thinking frames arrive → no thinking runs).
### `frontend/assets/app.js` — the restore path (task 03)
- `renderStoredMessage`: after the bubble exists, IF
`Array.isArray(m.segments) && m.segments.length` → `restoreTimeline(wrap,
m.segments)` and SKIP the legacy `m.thinking` block + the `m.tools` loop
(segments take precedence — they carry the same data in order); ELSE the
legacy path is BYTE-IDENTICAL (a pre-phase record — or a `BOR_STREAM_THINKING=0`
turn saved with the old client — restores exactly as today).
- `restoreTimeline`: iterate `m.segments` in order; group CONSECUTIVE
`kind:"tool"` entries into ONE `details.tool-calls-disclosure` (created
CLOSED — D5; summary `Tool call(s) (N)`; each line through the SAME
`appendToolLine` container-signature path with the EXISTING tOffset/tLimit/tPath
guards + the existing `appendTruncatedNote` call for `t.truncated` records —
the current loop's guards move verbatim inside the grouping); a `kind:"thinking"`
entry → its OWN `details.thinking` (created CLOSED; `.thinking-text` =
`renderMarkdown(entry.text)` — the restore convention stays escape-first);
unknown `kind` values are SKIPPED (never render from storage — textContent
only, the house rule). All elements inserted before the bubble.
### `frontend/assets/shared.js` — the shared page (task 03)
- `addSegments(wrap, segments)` — the shared-page parity copy of
`restoreTimeline` (pure render, always COLLAPSED — D5): the same grouping,
the same line bytes (the four label literals + the phase-95 truncation note
+ the phase-139/146 suffixes, already parity-pinned in `addToolLines`), the
same summary count; the `addThinkingBlock`/`addToolLines` legacy path is
BYTE-IDENTICAL when the record has no `segments`. The record-dispatch site
(~L456): `if (Array.isArray(m.segments) && m.segments.length)
addSegments(wrap, m.segments); else { the existing addThinkingBlock +
addToolLines }`.
### `tests/e2e/mock_llm.py` (task 04)
- `THINK_TOOLS_TRIGGER = "think out loud and use your tools"` — checked in the
streaming branch BEFORE the `_tool_flow` dispatch (the more-specific-phrase-wins
convention — the phrase contains both `think out loud` and `use your tools`,
so it must not fall through to the plain answer path; no other existing
trigger phrase is a substring of it, and no existing E2E question carries it —
verify both before wiring).
- `_thinking_tools_flow(body)` — stateless, shaped like `_tool_flow` (gated on
the `tools` parameter + the `<tools>` section, like the other marker flows):
- request 1 (tools offered, no tool results yet): `thinking` =
`"Step 1: list the documents to find the reference server config."` →
`_tool_call_stream("ls", {}, "call_0", thinking=thinking)`;
- request 2 (a `tool`-role top-level source listing — the agent's `ls` tree
format, no file lines yet): `"Step 2: drill into the first source."` → the
`_drill_target` scoped `ls` (id `call_1`), with thinking;
- request 3 (a `tool`-role folder listing with file lines — parse the FIRST
file line's labeled `source:` / `path:` fields, the phase-63/70 format):
`"Step 3: read the file line's document."` → `read` on the JOINED
combined `source/path` (id `call_2`), with thinking;
- request 4 (a `tool`-role read result — content starting with the agent's
`"Document <source/path>:"` header): `"Step 4: compose the answer from the
read document."` → the phase-37 answer convention: `Read <source/path>.
<first 80 chars of the read document's content>` (content stream, with
thinking ahead of it).
- `_tool_call_stream(name, arguments, call_id, thinking="")` — the optional
`thinking` param: when non-empty, yield `reasoning_content` chunks FIRST
(the 12-char split — the `_sse_stream` convention), then the existing
`tool_calls` frames + `finish_reason: "tool_calls"` (unchanged). Default
`""` → every pre-phase 3-arg caller streams BYTE-IDENTICAL.
- Docstring: the new trigger entry (the phase-158 flow) — the mock stays
mock-only by design (`E2E_REAL_LLM` note unchanged).
### `tests/e2e/test_thinking_timeline.py` (task 04 — dedicated suite, run in isolation)
The conftest `app_server`/`page`/`seeded_kb` fixtures + the phase-37 admin
login + the trigger question (the KB must be seeded so request 4's read result
exists — mirror `test_agent_document_tools.py`). Six tests:
1. **`test_timeline_order_after_completed_turn`** — after `done`: the brain
wrap's `.msg-body` children before the bubble are EXACTLY
`[details.thinking, details.tool-calls-disclosure, details.thinking,
details.tool-calls-disclosure, details.thinking, details.tool-calls-disclosure,
details.thinking]` (7 elements); the four `.thinking-text`s carry DISTINCT
`Step 1`…`Step 4` markers (distinctness = the runs are separate blocks, not
one lump); each disclosure summary is `Tool call (1)`; the answer bubble
carries the `Read <path>` echo.
2. **`test_runs_fold_at_rest`** — after `done`: NO `.thinking` /
`.tool-calls-disclosure` has the `open` attribute; the summaries are
visible (D5).
3. **`test_live_runs_open_while_streaming`** — during the turn (an
`expect.poll` predicate over the mock's 0.1 s pacing window): at least one
`.thinking` or `.tool-calls-disclosure` is `open` while the answer bubble is
still empty (the D5 live-open contract — the live feedback the owner keeps).
4. **`test_restored_timeline_and_record`** — RELOAD (same context): the
restored message renders the same 7-element timeline, all closed; the
localStorage `bor.chat.v1` record for that brain turn carries a `segments`
array of EXACTLY 7 entries in the pattern
`thinking, tool, thinking, tool, thinking, tool, thinking` (4 thinking runs
+ 3 tool entries).
5. **`test_shared_page_timeline`** — the phase-51 save + share flow, a FRESH
context on `/shared/<token>`: the same 7-element timeline renders, all
collapsed (the `shared.js` parity — `addSegments`).
6. **`test_no_thinking_turn_single_disclosure`** — a plain `use your tools`
question (NO thinking trigger): the turn renders EXACTLY ONE
`.tool-calls-disclosure` (3 lines, `Tool calls (3)`) and ZERO `.thinking`
(the run-grouping contract: no thinking frames → one tool run — the
phase-117 behavior for no-thinking turns is preserved).
## Tasks
1. `01_schema_segments.md` — the discriminated `ChatSegment` models + the
`ChatMessage.segments` field + the serializer omission + the integration
contract (accept / caps / byte-identical round-trip / shared read).
2. `02_live_timeline.md` — the per-run live rendering (run tracker, D2 anchor,
the clock re-target, the settle sites, the save payloads) + the JS source
unit pins.
3. `03_restore_and_shared.md` — the `restoreTimeline` dispatch in
`renderStoredMessage` + the `shared.js` `addSegments` parity + the unit pins.
4. `04_mock_and_e2e.md` — the mock `THINK_TOOLS_TRIGGER` flow + the dedicated
`test_thinking_timeline.py` story suite, run in isolation.
5. `05_regression_update_and_commit.md` — the phase-109 suite pin update + the
remaining unit-pin updates, the full gate, the documentation pass, the
atomic commit, the phase move.
## Testing & Quality
- Unit (new): `tests/unit/test_thinking_timeline.py` — the source-level pins:
the per-run creation (a NEW `details.thinking` when the run kind changes,
insert-before-bubble anchor — D2), the plain `Thinking` literal (D3), the
per-run disclosure + the `Tool call(s) (N)` per-run count, the
`armToolLineClock` reference (the `:last-child` query is GONE), the
`segRuns` → `buildSegments` mapping (empty-run skip, the tool-record shape),
the `delta` `closeCurrentRun` + the settle-pair sites → `closeAllRuns`, the
FIVE save-payload sites (done / finalizeFailedTurn + its 2 call sites /
stop path / pagehide), the restore dispatch (segments vs legacy — the
legacy path byte-identical), the `shared.js` `addSegments` parity, and
`innerHTML` ABSENT from the restore/shared builders (the house rule).
- Integration: `tests/integration/test_chats_api.py` (extended) — a record WITH
segments (both kinds, every additive tool field) is accepted and round-trips
through create/get; `segments` > 50 → 422; a thinking-segment text > 32 000 →
422; an unknown segment `kind` → 422 (`extra="forbid"` intact — a stray
`<b>`-ish key still 422s); a record WITHOUT `segments` round-trips with the
key ABSENT (never `null` — the phase-50 byte-identical contract, pinned);
the shared read (`/api/chats/<id>/shared` or the public read path) carries
segments (the public shape).
- Existing unit suites MUST stay green: every pin NOT broken by the redesign
(the tool-line label literals, the phase-117 deboxed CSS, the phase-87
`.tool-elapsed` behavior, the phase-139/146 suffix pins, the emoji guard,
the `test_tool_call_compact.py` pins — a no-thinking turn is one run, so the
phase-117 E2E contract is UNCHANGED).
- E2E: `tests/e2e/test_thinking_timeline.py` (new — the six tests above), green
**in isolation** (`uv run pytest tests/e2e/test_thinking_timeline.py -v
--no-cov`), mock-only.
- Regression E2E (task 05, in isolation): `test_thinking_display.py` (a
no-tools thinking turn is ONE run — the suite's contract is unchanged),
`test_tool_call_compact.py` (unchanged — no-thinking single-run),
`test_turn_progress_loader.py` (UPDATED in task 05 — the D15 pin → the
per-run contract), `test_big_read_progress.py` (the clock's visible behavior
is identical — must stay green UNCHANGED), `test_agent_document_tools.py`,
`test_share_chat.py`, `test_chat_persistence.py`, `test_smoke.py`.
- Coverage: `uv run pytest --cov=app --cov-report=term-missing` **>90%** on
`app/` (task 01's schema code is unit/integration-covered; the rest is held
by the suite).
- Lint/types: `uv run ruff check . && uv run pyright` clean.
- The phase is "Complete" only when the **entire** project test suite passes
100% (every test in the project, not just this phase's).
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the
project as it stands after this phase — fixing every inconsistency found,
**including pre-existing ones not caused by the phase**.
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are
user-visible (the timeline is a visible UI change — name it where the chat
thinking/tool-call display is described, if anywhere).
## Completion Criteria
- [ ] A completed tool+thinking turn renders `Thinking → Tool call (1) →
Thinking → Tool call (1) → … → answer` — the E2E's 7-element order pin
(task 04 test 1); the runs fold at rest (test 2) and stay open while
streaming (test 3).
- [ ] A reload restores the same timeline + the record's `segments` array
(test 4); a fresh context on `/shared/<token>` renders the same timeline
(test 5).
- [ ] A no-thinking tool turn is ONE disclosure, 3 lines (test 6) — the
phase-117 contract is preserved; `test_tool_call_compact.py` +
`test_thinking_display.py` + `test_big_read_progress.py` green in
isolation UNCHANGED.
- [ ] A pre-phase saved record (no `segments`) restores byte-identically
(the integration pin + the E2E regression suites).
- [ ] `uv run pytest` green (100% of the project suite);
`uv run pytest --cov=app --cov-report=term-missing` > 90%;
`tests/e2e/test_thinking_timeline.py` green in isolation;
`uv run ruff check . && uv run pyright` clean.
- [ ] `git diff --stat` limited to `app/schemas.py`, `frontend/assets/app.js`,
`frontend/assets/shared.js`, `tests/`, and the phase files.
- [ ] README.md and AGENTS.md are accurate and consistent with the whole
project (verified in the final pass).
- [ ] ONE owner-directed exception to "no behavior change in completed phases":
the phase-109 D15 re-open contract is REPLACED by the per-run blocks
(owner direction 2026-10-04 — recorded above); `test_turn_progress_loader.py`'
pin is updated in task 05 to the per-run contract (two thinking blocks,
each carrying its own round, open in its live window, folded at rest).
- [ ] One atomic `--no-gpg-sign` commit (below); the phase dir moved to
`.agents/phases/complete/`.
## Commit
```bash
git add -A .agents/ app/schemas.py frontend/ tests/ README.md && git commit --no-gpg-sign -m "feat(ui): render thinking and tool runs as a chronological timeline (per-run blocks, segments persistence)"
```
## Locked decisions
- **The SSE contract is UNCHANGED (A15 intact).** The frames already arrive in
true chronological order (`run_agent`'s yield order — verified in code); this
phase is client-grouping + record persistence only. `app/rag/` is untouched.
- **D1–D6 (owner, 2026-10-04)** — the per-run timeline, Option B placement,
the plain `Thinking` label, the additive `segments` record, open-live/
fold-at-rest, and the validated `ChatMessage.segments` field (the
`extra="forbid"` boundary preserved with phase-83-style caps).
- **A10 history wire UNCHANGED** — segments never travel to the LLM (the
concatenated `thinking` travels as `reasoning_content` exactly as today;
`HistoryTurn` + the mapper are untouched).
- **A13 UNCHANGED** — no migration (the record is opaque JSONB; the schema
field is validation at the boundary only).
- **A16 honoured** — one new story E2E suite + the task-05 regression updates;
mock-only by design. **A17 honoured** — one atomic `--no-gpg-sign` commit.
- **The phase-87 clock re-target** (the `:last-child` query → the tracked
reference) is a bug fix in service of D1; its visible behavior (the suffix on
the newest line) is byte-identical — `test_big_read_progress.py` stays green
unchanged. No other anchor changed.
@@ -0,0 +1,69 @@
# Task 01 — the validated `ChatMessage.segments` field (server)
**Phase:** `158_thinking_timeline` · **Story:** n/a
## Objective
Saved-chat writes accept the new optional `segments` array (a discriminated
`thinking`/`tool` union with phase-83-style caps), omit it when absent so
pre-phase records round-trip byte-identically, and reject malformed values at
the `extra="forbid"` boundary.
## Work
1. `app/schemas.py`:
- `ChatThinkingSegment` (a `BaseModel`, beside `ToolCall`): `kind:
Literal["thinking"]`, `text: str = Field(min_length=1, max_length=32_000)`
(`HistoryTurn`'s cap, phase 83 — docstring cross-reference).
- `ChatToolSegment` (a `BaseModel`): `kind: Literal["tool"]` + the EXACT
`ToolCall` field set (name / argument / offset / limit / path / truncated /
chars_shown / chars_total with their existing types and caps — mirror, do
not weaken; docstring cross-references `ToolCall` as the shape source).
- `ChatSegment = Annotated[Union[ChatThinkingSegment, ChatToolSegment],
Field(discriminator="kind")]`.
- `ChatMessage.segments: list[ChatSegment] | None = Field(default=None,
max_length=50)` — with the phase-83 voice comment (the cap sizing: far
past any realistic turn — the round cap is 10 → ≤ 20 entries; 50 matches
the `tools` cap).
- The EXISTING wrap serializer (`_drop_image_when_absent`) gains the
omission rule: after the two image pops, `if self.segments is None:
data.pop("segments", None)` — ABSENT, never `null` (the phase-50
round-trip contract; the phase-122 `SourceRef.image_url` omission
precedent, the phase-113 lesson applied up front: the field EXISTS so
the boundary does NOT 422 the done-time auto-save carrying it).
- The `ChatMessage` docstring gains the phase-158 paragraph (the shape,
the omission rule, the `extra="forbid"` boundary) in the established
voice; `HistoryTurn` and everything in `app/rag/` is UNTOUCHED (the
history wire stays `{who, text, thinking}`).
2. NO migration, NO router change (`app/api/chats.py` validates through
`ChatMessage` — `m.model_dump()` + `ChatMessage.model_validate(m)` already
carry the new field end to end, including the public shared read
`SharedChatOut.messages`).
## Testing & Quality
- Integration (extend `tests/integration/test_chats_api.py` — the file already
pins the brain-record contract, phase 83/113/120/123/124):
- a record WITH `segments` (one `thinking` entry + two `tool` entries
carrying every additive field: `offset/limit`, `path`,
`truncated/chars_shown/chars_total`) is accepted by `POST /api/chats` and
round-trips through `GET` (the shape preserved, entry order preserved);
- `segments` with 51 entries → 422; a thinking-segment `text` of 32 001
chars → 422; a segment with `kind: "bogus"` → 422; a record with a stray
top-level key (e.g. `"foo": 1`) still → 422 (the `extra="forbid"`
boundary intact);
- a record WITHOUT `segments` (a pre-phase brain record, the full phase-113
shape) round-trips with the key ABSENT from the served JSON (never
`null` — the byte-identity pin, the phase-50 contract);
- the shared read path (the public read endpoint's `messages`) carries the
segments of a saved record with them (the public shape, the phase-51
contract).
- Coverage: **>90%** on this task's new/modified code (the schema code is
exercised by the integration tests).
## Completion Criteria
- [ ] the three models + the field + the serializer rule land exactly as
designed (a source check)
- [ ] the new integration tests are green; the EXISTING
`test_chats_api.py` tests stay green unchanged
- [ ] `uv run pytest tests/integration -q` green; `uv run ruff check . && uv
run pyright` clean
- [ ] no change in `app/rag/`, no migration, no router change (a `git diff
--stat` scoped check)
@@ -0,0 +1,195 @@
# Task 02 — the per-run live timeline in `app.js`
**Phase:** `158_thinking_timeline` · **Story:** n/a
## Objective
Replace the single-block/single-disclosure turn rendering with the per-run
timeline: each contiguous thinking run streams into its own `details.thinking`
block, each contiguous tool-call run into its own
`details.tool-calls-disclosure`, all segments ABOVE the answer bubble in
arrival order (D2); the runs open live and fold at rest (D5); the phase-87
clock is re-targeted to a tracked line reference; the save payloads persist
the `segments` array (D4).
## Work
1. `frontend/assets/app.js` — turn state (the `runTurn` locals / module scope,
~L3065–L3080):
- `segRuns = []` — MODULE scope (beside `thinkingAcc`, which the pagehide
partial reads), reset to `[]` in `runTurn` with the other turn locals.
Entries: `{ kind: "thinking", text: "", records: [] }` /
`{ kind: "tool", text: "", records: [] }` (the tool entry's `records`
holds the `toolAcc` record objects — the SAME objects `toolAcc` holds,
so the phase-95 `tool_result` stamp (`t.truncated = true; t.chars_shown
= …; t.chars_total = …`, ~L3398–L3404) flows into both automatically —
NO new wiring).
- `currentRun = null` — turn-local (the active run's element + kind).
2. `frontend/assets/app.js` — the run builders (replace `ensureThinkingBlock`
+ the single-container logic in `appendToolLine`):
- `ensureThinkingRun(wrap)` — returns the CURRENT thinking run's
`details.thinking` if `currentRun?.kind === "thinking"`; else closes
`currentRun` (if any — `open = false`), creates a NEW
`details.thinking` (`open = true`, `<summary>Thinking</summary>` — the
phase-17 literal, D3 — built via the existing `innerHTML` template that
`ensureThinkingBlock` uses, OR createElement/textContent — either is fine,
but the RESTORE path's builder stays createElement/textContent (the house
rule pins `innerHTML` ABSENT from the restore/shared builders — see task
03)), inserts it `before body.querySelector(".bubble")` (the bubble
get-or-created first), pushes a fresh `{kind:"thinking",…}` to `segRuns`,
sets `currentRun`.
- `ensureToolRun(wrap)` — the same, for
`details.tool-calls-disclosure` (`open = true`,
`summary.tool-calls-summary` + the `div.tool-calls` list `role=list`
`aria-label="Tool calls"` — the phase-117 structure, now per run).
- `appendToolLine` — the signature becomes
`appendToolLine(container, name, argument, offset = null, limit = null,
scope = null)` (the CALLER passes the run's disclosure — no more
`body.querySelector(".tool-calls-disclosure")` single-container
derivation). EVERYTHING else in the function is BYTE-IDENTICAL: the
`.tool-calls` list get-or-create, the line bytes, the four pinned
`line.textContent` label literals, the phase-139 range suffix, the
phase-146 scope span, the `Tool call(s) (N)` count.
- `closeCurrentRun()` — folds `currentRun` (if any, `open = false`);
idempotent; no-op without a run.
- `closeAllRuns(wrap)` — closes EVERY open `details.thinking` +
`details.tool-calls-disclosure` in the wrap (defensive; the `done` +
stop/abort settle sites).
3. `frontend/assets/app.js` — the turn-handler branches:
- the `thinking` branch (~L3148): keep `settleToolLine()`,
`thinkingAcc += ev.text || ""`, `sawThinking`, `clearTurnTimeout()`,
`if (!wrap) wrap = addMessage("brain", "")`, `removeTyping()` — all
unchanged. REPLACE the `ensureThinkingBlock` + `block.open = true`
(phase-109 D15 re-open — SUPERSEDED by D1) with
`const block = ensureThinkingRun(wrap);` + the run's text extension:
the run's `text` accumulates `ev.text`; the pre-update pin-state /
bottom-follow logic (the `isThinkingNearBottom` capture +
`scrollTop = scrollHeight`, ~L3166–L3189) moves VERBATIM into this
branch, now operating on THIS block (per-block — the existing comment
block stays, updated to name the per-run placement).
- the `tool` branch (~L3193): keep the phase-139/146 guards,
`toolAcc.push(toolRecord)`, `clearTurnTimeout()`, the wrap
get-or-create, and the `toolStatus` label logic UNCHANGED. REPLACE
`appendToolLine(wrap, …)` + `armToolLineClock(wrap)` with:
`const disc = ensureToolRun(wrap); const line =
appendToolLine(disc, name, argument, frameOffset, frameLimit, framePath);`
(the function returns the appended `line` element — a one-line change,
the function already has it in scope) +
`armToolLineClock(wrap, line)`. The run record is the SAME `toolRecord`
object pushed to `toolAcc` (push it into the run's `records` too — or
derive the run's `records` from `segRuns` at save time via the call
order — the executor picks ONE, but the phase-95 stamp must reach the
`segments` payload: the simplest is the run's `records.push(toolRecord)`
alongside the `toolAcc.push(toolRecord)`).
- the `delta` branch (~L3300): `settleToolLine()` + `acc += …` + the
`thinking→streaming` transition UNCHANGED; replace
`closeThinkingBlock(wrap)` + `closeToolCalls(wrap)` with
`closeCurrentRun()`.
- the THREE remaining settle-pair sites — `finalizeFailedTurn`'s
`if (wrap)` block (L2990–L2991), the STOP path (L3477–L3478), and any
other `closeThinkingBlock(wrap); closeToolCalls(wrap);` pair (grep for
the pair — the redesign removes both functions): replace the pair with
`closeAllRuns(wrap);`.
- `removeTyping()` / the typing-indicator / the `#send-status` copy / the
UI state machine / the 300 s guard / the stream-drop guard / the
`thinking-without-answer` fallback: ALL UNCHANGED (§7.4 intact).
4. `frontend/assets/app.js` — the clock (the phase-87 re-target, ~L1479):
- `armToolLineClock(wrap, lineEl)` — the NEW second param: store `lineEl`
in a module-scope `toolLineEl` (reset to `null` with `toolLineWrap` in
`stopToolLineClock` and on re-arm); the interval body uses `toolLineEl`
(null-guarded — the wrap-reset no-op stays) instead of
`toolLineWrap?.querySelector?.(".tool-calls .tool-call:last-child")`.
The suffix element + the `(Ns)` text + the `TOOL_LINE_ELAPSED_AFTER_MS`
gate are UNCHANGED.
- `settleToolLine()` — the `querySelectorAll(".tool-elapsed")` removal stays
(it clears every line in the wrap — correct with multiple disclosures);
add `toolLineEl = null` alongside.
- the comment block updated to name the re-target (the multi-disclosure
fix, phase 158 D1).
5. `frontend/assets/app.js` — the save payloads (D4) — the FIVE sites:
- `buildSegments(segRuns)` — a helper (beside the turn state): maps the
runs IN ORDER — a thinking run → `{ kind: "thinking", text: run.text }`
ONLY when `run.text` is non-empty (an empty run stores nothing); a tool
run → one `{ kind: "tool", …record }` per record in order (spread the
record object — the additive `offset/limit/path` + the phase-95 stamp
fields ride through; NO re-validation on the client — the server's
`ChatMessage` boundary validates).
- the `done` save point (~L3355, the `rememberBrainTurn` meta):
`segments: buildSegments(segRuns).length ? buildSegments(segRuns) :
undefined` (the `undefined`-drops-the-key house pattern, D4) — the
`thinking: thinkingAcc || undefined` + `tools: toolAcc.length ? toolAcc :
undefined` lines are UNCHANGED.
- `finalizeFailedTurn` (L2983): the parameter object gains `segments` (the
TWO call sites — the stream-drop site ~L3431 and the network-error
site ~L3519 — pass `buildSegments(segRuns)`); the function's `if
(wrap)` saved record includes `segments` ONLY when the passed value is
a non-empty array (`segments: segments?.length ? segments :
undefined`); the existing `acc`/`thinking`/`tools` fields are
UNCHANGED.
- the STOP path's save point (L3487–L3491, the `stopped: true` meta):
gains the same `segments` field (non-empty only) — a stopped partial
restores its timeline.
- the pagehide partial (L3642–L3650, the module-scope read — `segRuns`
is module scope, so it is readable there): gains the same `segments`
field (non-empty only). The zero-frame fallback save (L3456–L3462)
needs NOTHING (zero frames → no runs → no key).
- the `thinking`/`tools` keys are UNCHANGED at every site.
6. `tests/unit/test_thinking_timeline.py` (NEW — the source-level house
pattern, the `js[fn : js.find("\n}\n", fn)]` body-extraction the existing
pins use):
- `ensureThinkingRun` body: creates `details` + `className = "thinking"` +
`open = true` + the plain `Thinking` summary literal (D3 — the phase-17
literal, NOT a numbered variant) + inserts before `.bubble` (D2 — the
anchor is the bubble, NOT the old `.tool-calls-disclosure ?? …` chain);
`ensureToolRun` body: `className = "tool-calls-disclosure"` + `open =
true` + the summary/list structure.
- `appendToolLine` signature: the first param is the container (no
`body.querySelector(".tool-calls-disclosure")` in the body — the
single-container derivation is GONE); the four label literals STILL
present (mirror the guard); returns the line.
- `armToolLineClock`: the `:last-child` query is GONE; a `toolLineEl`
reference is used; `settleToolLine` resets it.
- `closeCurrentRun` / `closeAllRuns` defined; the `delta` branch calls
`closeCurrentRun` (pin: appears in the delta-branch region); `done` +
the stop/abort settle call `closeAllRuns`.
- `buildSegments` present; the `done` save + the `finalizeFailedTurn`
call site + the pagehide partial all reference it (≥ 3 call sites).
- `innerHTML` NOT in `ensureThinkingRun`… — pin per the builder choice
made in Work item 2 (if the live builder reuses the phase-17 innerHTML
template, pin that; the RESTORE/SHARED builders (task 03) are pinned
innerHTML-free there).
- `segRuns` is module scope + reset in `runTurn`.
- Builder style: MIRROR the function it replaces (the live thinking
builder keeps the phase-17 static-structure convention — a static
template with NO data — the data flows through `renderMarkdown` /
`textContent` exactly as today; the live tool builder keeps the
phase-117 createElement/textContent style — `innerHTML` ABSENT from
`appendToolLine`, as pinned today). Pin the STRUCTURE + the no-data-
through-innerHTML rule, not the mechanism.
## Testing & Quality
- Unit: `tests/unit/test_thinking_timeline.py` (this task's pins) + UPDATE the
existing source pins THIS task's change breaks (inspect, in order:
`test_frontend_feedback.py`, `test_frontend_turn_loader.py` — the phase-109
D15 re-open pin becomes the per-run pin (a NEW block, not a re-open),
`test_frontend_tool_states.py` — the `appendToolLine` single-container pin,
`test_big_read_progress.py` — the clock-query pin, `test_frontend_failed_turn.py`
— the `finalizeFailedTurn` payload pin, `test_thinking_scroll.py`,
`test_chat_persistence.py` — ONLY the pins this task touches; the
restore-path pins are task 03's, the shared-page pins are task 03's). Every
pin this task does NOT break (the tool-line label literals, the phase-117
deboxed CSS, the phase-139/146 suffix pins, the emoji guard) stays green
UNCHANGED.
- Coverage: **>90%** on this task's modified code (the pins).
## Completion Criteria
- [ ] a live turn renders per-run blocks in arrival order (the code paths
land exactly as designed — the unit pins)
- [ ] the clock targets the tracked line reference (the `:last-child` query
is gone — the unit pin)
- [ ] the `done` / failed / pagehide save payloads carry `segments`
(non-empty only) — the unit pins
- [ ] `uv run pytest tests/unit -q` green (every pin this task breaks is
updated; every pin it doesn't touch stays green); `uv run ruff check .
&& uv run pyright` clean
- [ ] `git diff --stat` limited to `frontend/assets/app.js`,
`tests/unit/test_thinking_timeline.py`, and the unit pin files updated
in this task
@@ -0,0 +1,82 @@
# Task 03 — the restore path + the shared-page timeline
**Phase:** `158_thinking_timeline` · **Story:** n/a
## Objective
A restored brain record renders its `segments` as the collapsed timeline (the
grouping mirrors the live run structure); a record WITHOUT `segments` — every
pre-phase record — restores byte-identically through the untouched legacy
path; the shared page renders the same timeline from the same record shape.
## Work
1. `frontend/assets/app.js` — `renderStoredMessage` (~L2089):
- AFTER the bubble exists: `if (Array.isArray(m.segments) &&
m.segments.length) { restoreTimeline(wrap, m.segments); } else { THE
EXISTING `m.thinking` BLOCK + THE EXISTING `m.tools` LOOP,
BYTE-IDENTICAL (including the `closeToolCalls(wrap)` call — RENAME it
to `closeAllRuns(wrap)` — task 02 removed `closeToolCalls` — the
restore's folded-at-rest result is the same) }`.
- `restoreTimeline(wrap, segments)` (a new function, the house style —
createElement/textContent, `renderMarkdown` for the thinking text,
`innerHTML` ABSENT for the structure): iterate `segments` in order:
- a `kind: "thinking"` entry (guard: `typeof entry.text === "string"`)
→ its OWN `details.thinking` (created CLOSED — D5;
`<summary>Thinking</summary>` — the phase-17 literal, D3;
`.thinking-text` = `renderMarkdown(entry.text)` — the existing
restore convention, escape-first), inserted before the bubble;
- CONSECUTIVE `kind: "tool"` entries → ONE
`details.tool-calls-disclosure` (created CLOSED — D5;
`summary.tool-calls-summary` + the `div.tool-calls` list
`role=list` `aria-label="Tool calls"` — the phase-117 structure),
each entry's line through `appendToolLine` (task 02's container
signature) with the EXISTING tOffset/tLimit/tPath guards
(`Number.isInteger(x) && x >= 1` / the string guard — byte-identical,
the phase-139/146 restore precedent) + the EXISTING
`appendTruncatedNote(wrap, arg, …)` call for `entry.truncated`
records (the phase-95 marker re-render — moved verbatim inside the
grouping); the summary count `Tool call(s) (N)` set once per
disclosure after its entries;
- an entry with an unknown `kind` (or a malformed shape) → SKIPPED
(never render from storage — textContent only, the house rule;
a guard comment naming the skip).
2. `frontend/assets/shared.js` — `addSegments(wrap, segments)` (new, the
shared-page parity copy of `restoreTimeline` — pure render, always
COLLAPSED — D5): the same grouping, the same line bytes (the four label
literals + the phase-95 truncation note + the phase-139/146 suffixes —
the parity `addToolLines` already carries; mirror it), the same summary
count, the same unknown-`kind` skip. The record-dispatch site (~L456):
`if (Array.isArray(m.segments) && m.segments.length) {
addSegments(wrap, m.segments); } else { THE EXISTING addThinkingBlock +
addToolLines, BYTE-IDENTICAL }`. The `addThinkingBlock` / `addToolLines`
functions themselves are UNCHANGED (the legacy path is intact).
3. `tests/unit/test_thinking_timeline.py` — ADD the pins:
- the `renderStoredMessage` dispatch (segments take precedence; the
legacy path intact when `segments` is absent — pin the
`Array.isArray(m.segments) && m.segments.length` guard);
- `restoreTimeline` body: `document.createElement("details")` +
`className = "thinking"` (NO `open = true` — collapsed, D5) + the plain
`Thinking` literal + the consecutive-tool grouping (one disclosure per
run — pin the grouping loop) + the `appendTruncatedNote` call + the
unknown-`kind` skip; `innerHTML` ABSENT (structure built via
createElement/textContent).
- the `shared.js` `addSegments` body: the same structure pins (collapsed,
the parity) + `innerHTML` ABSENT; the dispatch guard in `shared.js`.
## Testing & Quality
- Unit: `tests/unit/test_thinking_timeline.py` (this task's pins) + UPDATE the
existing source pins THIS task's change breaks (inspect:
`test_chat_persistence.py` — the restore-path pins, `test_shared_page.py` —
the `addToolLines`/dispatch pins; every pin this task does NOT break stays
green UNCHANGED).
- Coverage: **>90%** on this task's modified code (the pins).
## Completion Criteria
- [ ] the `restoreTimeline` + `addSegments` builders land exactly as designed
(the unit pins)
- [ ] the legacy restore / shared paths are byte-unchanged (a `git diff`
scoped check — only the dispatch guard is added)
- [ ] `uv run pytest tests/unit -q` green; `uv run ruff check . && uv run
pyright` clean
- [ ] `git diff --stat` limited to `frontend/assets/app.js`,
`frontend/assets/shared.js`, `tests/unit/test_thinking_timeline.py`,
and the unit pin files updated in this task
@@ -0,0 +1,115 @@
# Task 04 — the mock interleaved flow + the dedicated E2E suite
**Phase:** `158_thinking_timeline` · **Story:** n/a
## Objective
A deterministic mock trigger that interleaves a DISTINCT reasoning run before
every tool call and before the final answer (the phase-158 flow), and the
dedicated `test_thinking_timeline.py` Playwright suite that pins the
timeline contract (order / fold-at-rest / live-open / restore + record /
shared-page parity / the no-thinking single-run grouping).
## Work
1. `tests/e2e/mock_llm.py`:
- `THINK_TOOLS_TRIGGER = "think out loud and use your tools"` (beside
`THINKING_TRIGGER`, L992) + the docstring entry for the flow (the
four-request stateless shape, the `Step N` markers, the phase-37
answer convention). BEFORE wiring, VERIFY (and note in the docstring):
no existing trigger phrase is a substring of it, and NO existing E2E
question contains it (grep `tests/e2e/*.py` for the phrase — the
convention that keeps the other suites byte-unaffected).
- `_thinking_tools_flow(body) -> tuple[str, ...] | None` (shaped like
`_tool_flow`, L1820 — the same stateless discrimination, the same
`<tools>` + `tools`-parameter gating, reusing `_drill_target` /
`_first_file_line` / the `read`-result header check):
- request 1 (tools offered, no tool results yet):
`thinking = "Step 1: list the documents to find the reference server
config."` → `_tool_call_stream("ls", {}, "call_0", thinking)`;
- request 2 (a `tool`-role top-level source listing — no file lines
yet): `thinking = "Step 2: drill into the first source."` → the
`_drill_target` scoped `ls` (id `call_1`), with thinking;
- request 3 (a `tool`-role folder listing WITH file lines — the
phase-63/70 labeled `source:` / `path:` format): `thinking = "Step 3:
read the file line's document."` → `read` on the JOINED combined
`source/path` (id `call_2`), with thinking;
- request 4 (a `tool`-role read result — content starting with the
agent's `"Document <source/path>:"` header): `thinking = "Step 4:
compose the answer from the read document."` → the phase-37 answer
convention: `Read <source/path>. <first 80 chars of the read
document's content>` (the `_sse_stream` content path — the
`thinking=` param of `_sse_stream` already exists — pass it; the
final answer's thinking streams ahead of the content, exactly like
the `think out loud` answer path).
- `_tool_call_stream(name, arguments, call_id, thinking="")` (L3813):
the optional fourth param — when non-empty, yield
`reasoning_content` chunks FIRST (the 12-char split — the
`_sse_stream` convention), then the EXISTING `tool_calls` frames +
`finish_reason: "tool_calls"` UNCHANGED. Default `""` → every
pre-phase 3-arg caller (the ~10 call sites) streams BYTE-IDENTICAL
(verify with the existing tool-flow E2E suites in the regression
pass). The docstring updated (the param, the phase-158 flow).
- the `chat_completions` streaming dispatch: check
`_thinking_tools_flow(body)` BEFORE the `_tool_flow(body)` dispatch
(L4453 region) — the more-specific-phrase-wins convention (the phrase
contains `use your tools`, so the plain `_tool_flow` must NOT see
it); on a hit, route each flow stage through the same
`StreamingResponse` wrapper the `_tool_flow` stages use.
2. `tests/e2e/test_thinking_timeline.py` (NEW — the six tests, the phase
design's contract):
- fixtures: the conftest `app_server` / `page` / `seeded_kb` (mirror
`test_agent_document_tools.py` — the admin login, the seeded KB so
request 4's read result exists);
- 1. `test_timeline_order_after_completed_turn` — the trigger
question; after the `done` state: the brain wrap's `.msg-body`
children before the bubble are EXACTLY `[details.thinking,
details.tool-calls-disclosure, details.thinking,
details.tool-calls-disclosure, details.thinking,
details.tool-calls-disclosure, details.thinking]` (assert the
tag+class sequence — 7 elements); the four `.thinking-text`s carry
DISTINCT `Step 1`…`Step 4` markers (a substring assert each —
distinctness proves the runs are separate blocks, not one lump); each
disclosure summary is `Tool call (1)`; the answer bubble carries the
`Read ` echo.
- 2. `test_runs_fold_at_rest` — after `done`: NO `.thinking` /
`.tool-calls-disclosure` has the `open` attribute (a `count` assert
on `[open]`); the summaries are visible.
- 3. `test_live_runs_open_while_streaming` — an `expect.poll` predicate
over the mock's pacing window (the tool frames' 0.1 s + the thinking
stream): a `.thinking` or `.tool-calls-disclosure` is `open` while
the answer bubble is still EMPTY (the D5 live-open contract).
- 4. `test_restored_timeline_and_record` — RELOAD (same context): the
restored message renders the same 7-element sequence, all closed; the
localStorage record (read via `page.evaluate` on the `bor.chat.v1`
storage key — the phase-14 key) for that brain turn carries a
`segments` array of EXACTLY 7 entries in the pattern
`thinking, tool, thinking, tool, thinking, tool, thinking` (assert the
`kind` sequence + the `Step N` text on the thinking entries + the
`name` on the tool entries); the `thinking` (full) + `tools` (3
entries) keys are STILL present (D4 — the legacy fallback fields).
- 5. `test_shared_page_timeline` — the phase-51 save + share flow (the
`test_share_chat.py` pattern), a FRESH context on `/shared/<token>`:
the same 7-element sequence renders, all collapsed, the `Step N`
markers visible when expanded (expand ONE — the click on a summary —
then assert), the answer bubble carries the `Read ` echo.
- 6. `test_no_thinking_turn_single_disclosure` — a plain `use your
tools` question (NO thinking trigger): EXACTLY ONE
`.tool-calls-disclosure` (3 `.tool-call` lines, summary `Tool calls
(3)`) and ZERO `.thinking` (the run-grouping contract — no thinking
frames → one tool run — the phase-117 behavior preserved).
## Testing & Quality
- The mock change is exercised by the suite (the trigger flow) + the
existing tool-flow suites stay byte-unaffected (the `thinking=""` default
— verified in the regression pass, task 05).
- E2E: `uv run pytest tests/e2e/test_thinking_timeline.py -v --no-cov`
green **in isolation** (prereq: `podman compose up -d db`), mock-only.
- Coverage: the mock is test code (no `app/` change — the coverage floor is
held by the untouched suite; task 01's schema code is covered there).
## Completion Criteria
- [ ] the mock trigger + flow + the `_tool_call_stream` param land exactly
as designed; the pre-phase callers are byte-unchanged (a `git diff`
scoped check)
- [ ] all six tests pass in isolation
- [ ] `uv run pytest tests/e2e/test_thinking_timeline.py -v --no-cov` green
in isolation; `uv run ruff check . && uv run pyright` clean
@@ -0,0 +1,85 @@
# Task 05 — the phase-109 suite pin update, the full gate, docs pass, commit
**Phase:** `158_thinking_timeline` · **Story:** n/a
## Objective
Update `test_turn_progress_loader.py` from the phase-109 D15 re-open pin to
the per-run contract (the ONE owner-directed behavior change — recorded in
the phase design), run the full completion gate (unit + integration,
coverage >90%, the phase E2E + the regression E2E suites in isolation,
lint + types), run the documentation & consistency pass, and cut the single
atomic commit.
## Work
1. `tests/e2e/test_turn_progress_loader.py` — the D15 pin → the per-run
contract (the mock's turn-progress flow is `delta → tool → thinking →
delta → thinking → done` — the first thinking run starts AFTER the
first delta):
- the re-open pin (`test_thinking_block_reopens_after_delta_with_visible_loader`
— "the thinking block re-opens after the answer has started … the
scratchpad carries both thinking rounds") becomes the PER-RUN pin:
after the turn, the brain wrap holds TWO `.thinking` blocks (the
`count` assert), EACH carrying its OWN round's text (the flow's two
distinct reasoning texts — assert a marker on each, the way the mock's
two reasoning streams are already distinct); both are FOLDED at rest
(no `[open]`); the visible-during-streaming half of the pin (the
loader visible, the thinking text non-empty and open while its run
streams) is kept — a run is open in its live window (D5 replaces D15's
re-open: a new block instead of a re-opened one — the user sees the
new thinking either way; the D15 symptom — "the frozen chat" — stays
fixed). The module docstring updated (the phase-158 supersession,
owner direction 2026-10-04, recorded).
- the other tests in the file (the loader visibility pins) stay green
UNCHANGED — verify.
2. The full gate (the phase's Testing & Quality section):
- `uv run pytest` — the ENTIRE project suite, 100% green;
- `uv run pytest --cov=app --cov-report=term-missing` — **>90%** on
`app/`;
- `uv run pytest tests/e2e/test_thinking_timeline.py -v --no-cov` — the
phase E2E, green in isolation (prereq: `podman compose up -d db`);
- the regression E2E suites, each green in isolation
(`-v --no-cov`): `test_thinking_display.py`,
`test_tool_call_compact.py`, `test_turn_progress_loader.py` (the
UPDATED pins), `test_big_read_progress.py` (UNCHANGED — the clock's
visible behavior is identical), `test_agent_document_tools.py`,
`test_share_chat.py`, `test_chat_persistence.py`, `test_smoke.py`;
- `uv run ruff check . && uv run pyright` clean.
- If a regression suite fails on a pin the redesign broke that tasks
02–04 missed: update THAT pin to the per-run contract (the same
supersession logic as item 1 — note it in the commit body), and
re-run the suite in isolation.
3. The documentation & consistency pass (the phase's Documentation &
Consistency section):
- `README.md` + `AGENTS.md` reviewed in full against the project as it
stands after this phase; every inconsistency fixed, **including
pre-existing ones** (the README's chat thinking/tool-call display
description, if any, gains the timeline sentence).
- the `app/schemas.py` docstring voice (task 01) + the
`frontend/assets/app.js` / `shared.js` comment blocks (tasks 02–03)
read in the established phase-citation voice (spot-check).
4. The commit + the phase move:
- `git add -A .agents/ app/schemas.py frontend/ tests/ README.md && git
commit --no-gpg-sign -m "feat(ui): render thinking and tool runs as a
chronological timeline (per-run blocks, segments persistence)"`
(the AGENTS.md rule 8 + A17; the LLM-Generated trailer per the
git-commit skill's convention for this repo's commits — check the
last few commits' bodies for the trailer form and match);
- `mv .agents/phases/todo/158_thinking_timeline
.agents/phases/complete/158_thinking_timeline`.
## Testing & Quality
- The gate above IS this task's testing; no new code.
- Coverage: the >90% floor (the gate).
## Completion Criteria
- [ ] `test_turn_progress_loader.py` updated to the per-run contract +
green in isolation (item 1)
- [ ] the FULL gate green (item 2 — every line)
- [ ] README.md + AGENTS.md accurate and consistent with the whole project
(item 3 — the final pass)
- [ ] `git diff --stat` of the phase's total footprint limited to
`app/schemas.py`, `frontend/assets/app.js`,
`frontend/assets/shared.js`, `tests/`, the docs, and the phase files
(the phase design's scope check)
- [ ] one atomic `--no-gpg-sign` commit; the phase dir moved to
`.agents/phases/complete/`
@@ -0,0 +1,22 @@
That last `--co` (collect-only) run was my probe mistake — it shows 34% because no tests execute under collect-only. The valid coverage run was the real gate command executed earlier: `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (exit 0, all 3544 tests executed). All gates are verified.
## Phase 153 — final verification pass report
**Verified (all tasks 01–04 complete in `complete/`):**
- Modal `fullPageUrl(source, path, back = null)` + `openDocumentModal(…, back = null)` with module-level `currentBack`; 5 chat call sites pass `"/"`, sources.js call site unchanged (byte-identical Sources path); `documentUrl` comment now true
- Copy-path control (shared `renderDocument` core, icon+text, text = accessible name) on both surfaces; per-page clipboard helpers with the fallback-field + status-line contract; `#doc-copy-announcer` live region; styles + 44px touch floor
- Unit pins: `&back=%2F` / no-`back` byte-identity, `currentBack` single-writer guard, copy strings byte-pinned, phase-13 regression pins untouched
**Test / lint / coverage results:**
- `uv run pytest` → **3544 passed**, 0 failed (full unit + integration)
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90% gate)
- `uv run pytest tests/e2e/test_document_viewer_context.py -v --no-cov` → **8 passed** in 47.33s, in isolation (DB up, mock LLM)
- `uv run ruff check . && uv run pyright` → clean (0 errors/warnings)
**Completion criteria:** all met — (1) chat→modal→Full page: `href="/"` + "Chat"; Sources→modal→Full page: `href="/sources.html"` + "Sources" with `back=` asserted absent; (2) no-`back` URLs byte-identical to pre-phase; (3) Copy path: "Path copied." on success (clipboard readback), prefilled select-on-focus fallback field + "Path ready — copy it from the field." on rejected clipboard, both surfaces parameterized; (4) copied string exactly `docs/homelab/kubernetes.md`; (5) full suite green, E2E isolated green, lint clean, coverage 99%; (6) commit by harness.
**Documentation:** README.md reviewed in full — phase-153 feature already named in the Chat section; routes, env reference, workflows, deployment all consistent — no changes needed. AGENTS.md reviewed in full — rules, gates, and quick-reference commands verified against the repo (incl. `test_smoke.py`, `.env.example`, gitignore entries) — consistent, no changes needed.
**Notable:** pytest 9.1.1 quirk — a doubled `-q` (pyproject `addopts` + explicit `-q`) swallows the final "N passed" summary line while still exiting 0; the plain `uv run pytest` gate command prints it. No defects found in prior work.
**Next pending phase:** `154_sync_status_freshness`.
@@ -0,0 +1,121 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................................ [ 99%]
................ [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 270 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 443 0 100%
--------------------------------------------------
TOTAL 5719 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,20 @@
All done — the E2E failure was a stale pre-phase pin (it asserted the chat-origin modal "no back param" behavior, which is exactly what this task changes). Final verification is complete.
**Report — Phase 153, Task 01 (modal back context)**
**Implemented**
- `document-modal.js`: `fullPageUrl(source, path, back = null)` appends `&back=` + `encodeURIComponent(back)` only when truthy (null/absent → byte-identical to pre-phase); `openDocumentModal(…, back = null)` resets module-level `currentBack` at the top of every open; no-skeleton `window.open` fallback + "Full page" anchor both read it; contract comments updated (header, L39 bullet, builder block, docstring — citing `documentUrl`, not restating).
- `app.js`: all five chat-view call sites now pass `"/"`; the `documentUrl` comment corrected — the chip URL is now genuinely what the modal's "Full page" link points at on the chat path.
- `sources.js` L2023 untouched (null → no param → `/sources.html` default — byte-identity contract).
**Tests / lint / coverage**
- `uv run pytest` → **3530 passed** (100% green); `--cov=app` → **99%** (>90% gate)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors / 0 warnings
- E2E in isolation (all green): `test_modal_back_button` 4, `test_document_back_navigation` 3, `test_document_viewer` 8, `test_chat_answer_clusters` 4, `test_image_documents` 4
- New unit pins: `fullPageUrl("s","p")` → no `&back=`; `fullPageUrl("s","p","/")` → `&back=%2F` (node behavioral); `currentBack` single declaration + reset before the guard; both call sites read `currentBack` (3 `fullPageUrl(` sites total); `back = null` default. Stale pins updated in 5 unit files + 1 E2E file.
**Docs:** no doc impact (the `back` param is the pre-existing phase-13 contract; the user-visible Copy-path naming is task 02's job).
**Decisions:** forward re-open (`popstate`) passes `back = null` per the prescribed single-variable mechanism — the entry shape stays `{ modal, source, path }` (phase-144 pins untouched).
**Next pending task:** `02_copy_path.md`
@@ -0,0 +1,121 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................................ [ 99%]
.. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 270 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 443 0 100%
--------------------------------------------------
TOTAL 5719 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,16 @@
All gates green. Final report:
**Phase 153 · task 02 — Copy path — COMPLETE**
**Implementation** — the icon+text "Copy path" control is built by the ONE shared `renderDocument` core (after the path code, before the origin anchor) so both surfaces get the identical shape; each surface wires it with its own page-local clipboard helper (per-page duplication house style, no new shared module):
- `frontend/assets/document.js` — `copyPathButton()` (createElementNS, no innerHTML — the XSS pin survives), page-local `copyPathWithFallback()` (clipboard in a try; failure → prefilled select-on-focus fallback field + "Path ready — copy it from the field."; success → transient "Path copied." auto-clear ~4s; re-click re-attempts, never clears to empty). The page's state floats in a `.doc-copy-pop` block anchored under the button (the nowrap `#doc-meta` never grows the bar).
- `frontend/assets/document-modal.js` — modal-local `copyPathWithFallback()` + `wireCopyPath()`; nodes land inline in the wrapping `#doc-modal-meta` right after the control; `#doc-modal-desc` is the announcer.
- `frontend/document.html` — visually-hidden `#doc-copy-announcer` (role=status). `frontend/assets/styles.css` — `.doc-copy-path` / `.doc-copy-status` / `.doc-copy-pop` / `.doc-path-fallback` (+ the ≤640px 44px touch floor). Copied string: `source + "/" + path` (spaces intact). README updated.
**Gates (all green)**
- `uv run pytest tests/unit/ tests/integration/ --cov=app --cov-report=term -q` → exit 0, 3528 passed, app coverage **99%**
- `uv run pytest tests/e2e/test_document_viewer.py tests/e2e/test_modal_back_button.py tests/e2e/test_document_back_navigation.py tests/e2e/test_git_source_links.py -v --no-cov` → **19 passed** (regression check; task 04 owns the new E2E file)
- `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors, 0 warnings
- New unit pins (8): `tests/unit/test_document_viewer.py` — shared-core button shape + meta-row slot, page/modal helper source pins, node behavior (exact `source/path` clipboard string, success/failure/re-attempt/auto-clear, fallback field prefill + select-on-focus), the announcer node, the CSS.
Repo left uncommitted (phase protocol: the harness commits at PHASE_COMMIT; tasks 03/04 not started).
@@ -0,0 +1,121 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................................ [ 99%]
......... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 270 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 443 0 100%
--------------------------------------------------
TOTAL 5719 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,10 @@
**Phase 153 · Task 03 — unit pins: COMPLETE**
**Pins added (additive only; zero existing pins touched — phase-13 pins verified byte-identical to HEAD):**
- `test_frontend_doc_modal.py` +3: `fullPageUrl` node behavior — `back=null` ≡ absent ≡ exact pre-phase string `/document.html?source=H%20omelab&path=a%2Fb.md`; `back="/" ` → `…&back=%2F`; `openDocumentModal` node behavior — no-skeleton `window.open` fallback + "Full page" anchor both carry the stored `back` (chat → `%2F`, Sources-style → byte-identical, no param)
- `test_document_viewer.py` +4: phase-13 resolution guard (byte pin of the exact extracted block + node run: missing/empty/garbage/`//evil`/`javascript:` → `/sources.html`+"Sources"; `/` → "Chat"; other same-origin → "Back"); copy-path composition end-to-end via the real `wireCopyPath` click handler and page `render()` wiring — exact `H omelab/a/b c.md` bytes on the fake clipboard, success/failure strings, exact fallback prefill; `document.html` Copy-path static nodes (`header.doc-header` float anchor, `#doc-copy-announcer`, `#doc-meta`)
**Gates:** `uv run pytest tests/unit` → 2784 passed · `uv run pytest` (unit+integration) → 3544 passed · `uv run ruff check . && uv run pyright` → clean (0 errors)
**Docs:** no doc impact (test-only change)
**Task file** moved to `.agents/phases/complete/` (protocol precedent: tasks 01/02)
**Next pending task:** `04_e2e_viewer_context.md`
@@ -0,0 +1,121 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................................ [ 99%]
................ [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 270 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 443 0 100%
--------------------------------------------------
TOTAL 5719 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,13 @@
**Phase 153 task 04 — complete.**
- **Implemented:** `tests/e2e/test_document_viewer_context.py` (8 tests) — the house fixture idiom (mock LLM + per-module DB truncate/re-import, scripted summary-read chat turn, Sources drill):
- chat chip → modal → "Full page" (new tab via `expect_popup`) → `#doc-back` `href="/"` + "Chat"
- Sources row → modal → "Full page" → `href="/sources.html"` + "Sources", `back=` param asserted **absent**
- direct chip-link regression guard: href still carries `&back=%2F` byte-for-byte (verified by navigating to the exact href — middle-click is unreliable in headless Chromium)
- Copy path success (both surfaces, parametrized): granted-clipboard idiom, "Path copied." + live region, clipboard content byte-pinned to `docs/homelab/kubernetes.md`
- Copy path fallback (both surfaces): ungranted origin → prefilled `.doc-path-fallback` field, focused with full content selected, "Path ready — copy it from the field."
- direct open without `back` → `/sources.html` + "Sources" (phase-13 guard)
- **Results:** `uv run pytest tests/e2e/test_document_viewer_context.py -v --no-cov` → 8 passed; `uv run pytest --cov=app --cov-report=term-missing` → 3544 passed, 0 failed, app/ 99% (>90%); `uv run ruff check . && uv run pyright` → clean
- **Docs:** README.md — added the modal "Full page" origin-back sentence (Copy-path paragraph was already added by task 02); AGENTS.md needs no change (operational rules only)
- **Decisions:** verified clipboard/middle-click idioms with a scratch E2E before pinning (scratch deleted); no real-LLM dependency, no fixture beyond the established idiom
- **Next pending task:** phase 153 is complete (harness moves it); next phase `154_sync_status_freshness`
@@ -0,0 +1,121 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................................ [ 99%]
................ [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 270 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 443 0 100%
--------------------------------------------------
TOTAL 5719 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

+12
View File
@@ -80,11 +80,23 @@ figure is the citation, phase 130). Clicking a chip or figure opens the
document in an **almost-fullscreen modal** on the same page (no new tab);
the browser **Back** button closes the modal — instead of navigating the
view behind it — and Forward re-opens the same document (phase 144). The
modal's **Full page** link opens the dedicated `/document.html` viewer in a
new tab carrying its origin: a document opened from Chat returns through
the viewer's back link to Chat, one opened from Sources returns to Sources
(phase 153, feeding the phase-13 `back` contract on every path). The
onboarding suggestion chips
follow the opening questions of the 3 most recent saved chats (each
chat's first question — a follow-up never chips, phase 103) — on a
fresh deployment they seed from `BOR_SUGGESTIONS`.
The document's meta row (the chat modal **and** the standalone viewer at
`/document.html`) carries a **Copy path** button: one click copies the
document's `source/path` (the exact form the `read` tool, the chips, and
the Sources table use) to the clipboard; on a non-secure (http) homelab
origin the browser rejects the clipboard, so the click instead offers the
full path in an inline fallback field that selects itself on focus —
click or Tab, then copy (phase 153).
### RAG / Knowledge base (`/sources.html`)
The indexed document list. The *Path* column opens each document in the same
+12 -9
View File
@@ -439,10 +439,13 @@ function scrollReveal(wrap, behavior = SCROLL) {
* 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
* (document-modal.js) — phase 153 (task 01) made the chip's click
* pass THIS SAME `back` to openDocumentModal, so this URL is also
* what the modal's "Full page" link points at (the chat path:
* `&back=%2F` rides along; Sources links omit it on both sides, so
* their URLs stay byte-identical to pre-phase). 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
@@ -1697,7 +1700,7 @@ function appendSources(wrap, sources) {
chip.addEventListener("click", (e) => {
e.preventDefault(); // no new tab (phase 26) — the modal takes over
e.stopPropagation();
openDocumentModal(s.source, s.path, chip);
openDocumentModal(s.source, s.path, chip, "/"); // back → the chat page (phase 153)
});
chip.textContent = label;
chip.title = label;
@@ -1759,7 +1762,7 @@ function appendRelated(wrap, related) {
link.addEventListener("click", (e) => {
e.preventDefault(); // no new tab (phase 26) — the modal takes over
e.stopPropagation();
openDocumentModal(s.source, s.path, link);
openDocumentModal(s.source, s.path, link, "/"); // back → the chat page (phase 153)
});
link.textContent = docLabel;
link.title = docLabel; // full path as the native tooltip (chip pattern)
@@ -1801,7 +1804,7 @@ function appendSourceImageFigure(meta, s) {
fig.addEventListener("click", (e) => {
e.preventDefault(); // no new tab (phase 26) — the modal takes over
e.stopPropagation();
openDocumentModal(s.source, s.path, fig);
openDocumentModal(s.source, s.path, fig, "/"); // back → the chat page (phase 153)
});
const img = document.createElement("img");
img.className = "source-image-img";
@@ -1822,7 +1825,7 @@ function appendSourceImageFigure(meta, s) {
chip.addEventListener("click", (e) => {
e.preventDefault(); // no new tab (phase 26) — the modal takes over
e.stopPropagation();
openDocumentModal(s.source, s.path, chip);
openDocumentModal(s.source, s.path, chip, "/"); // back → the chat page (phase 153)
});
chip.textContent = label;
chip.title = label;
@@ -1905,7 +1908,7 @@ function appendAnswerImage(wrap, ref) {
fig.addEventListener("click", (e) => {
e.preventDefault(); // no new tab (phase 26) — the modal takes over
e.stopPropagation();
openDocumentModal(ref.source, ref.path, fig);
openDocumentModal(ref.source, ref.path, fig, "/"); // back → the chat page (phase 153)
});
const img = document.createElement("img");
img.className = "answer-image-img";
+140 -11
View File
@@ -9,7 +9,8 @@
* page bundle, same single-evaluation design as header.js).
*
* Contract:
* • openDocumentModal(source, path, triggerEl) — shows the #doc-modal
* • openDocumentModal(source, path, triggerEl, backEl = null) —
* shows the #doc-modal
* overlay (the phase-26 task-01 skeleton) in its loading state,
* moves focus into #doc-modal-content (tabindex="-1" — programmatic
* focus target), fetches GET /api/documents/content (the SAME
@@ -38,8 +39,29 @@
* convention).
* • #doc-modal-open ("Full page") is the escape hatch to the
* dedicated viewer: its href is set on open to the same
* /document.html?source=…&path=… URL the chip/table link carries,
* so the no-JS / direct-link page is always one click away.
* /document.html?source=…&path=…&back=… URL the chip/table link
* carries (phase 153 — the open's `back` rides through
* fullPageUrl, the app.js::documentUrl contract; a null `back`
* keeps the URL byte-identical — the Sources path), so the
* no-JS / direct-link page is always one click away.
* • "Copy path" (phase 153, task 02): the meta block's
* icon+text control (built by document.js's shared
* renderDocument core — the SAME shape as /document.html's
* titlebar; this module wires it with the modal's OWN
* page-local clipboard helper — the per-page duplication
* house style, no new shared module). The copied string is
* `source + "/" + path` (ASSUMPTION A1 — the read-tool form;
* the same two values the meta row renders); success shows a
* transient "Path copied." status (auto-clears ~4s, the toast
* idiom — visual only) and #doc-modal-desc reads the outcome
* (one a11y announcer); the rejected-clipboard failure
* renders the inline fallback field (input-like, prefilled,
* select-on-focus — the .share-link-fallback pattern) + the
* "Path ready — copy it from the field." status line INSIDE
* #doc-modal-meta (the block wraps — no clip rule; the nodes
* sit right after the control). A failed copy never clears
* the previous state into nothing (replaced in place); a
* re-click re-attempts.
*
* Import safety: the top level only binds controls that exist on the
* page (a missing element is a no-op — the header.js pattern). document
@@ -84,15 +106,30 @@ let lastPrev = null;
* not replaceState it again). Set and reset by the popstate handler
* alone. */
let closingViaBack = false;
/* The `back` the CURRENT open carries to the "Full page" escape hatch
* (phase 153, task 01): the modal is one-at-a-time by its own
* contract, so a single variable is sound. Reset at the top of EVERY
* openDocumentModal (a null back keeps the URL byte-identical — the
* Sources path); the no-skeleton fallback and the anchor both read it. */
let currentBack = null;
/* Same encoding the chips and the Sources table links use — both query
* values percent-encoded (real paths contain slashes, sometimes spaces). */
* values percent-encoded (real paths contain slashes, sometimes spaces).
* Phase 153 (task 01): `fullPageUrl`'s optional `back` (default null)
* is appended + encoded ONLY when truthy — a null/absent `back` keeps
* the URL byte-identical to pre-phase (the Sources path — the
* viewer's /sources.html default). The encoding is the one
* app.js::documentUrl documents (percent-encoded query values; `back`
* is a same-origin relative URL, never a URL with its own query). */
function contentUrl(source, path) {
return "/api/documents/content?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
}
function fullPageUrl(source, path) {
return "/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
function fullPageUrl(source, path, back = null) {
let url =
"/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
if (back) url += "&back=" + encodeURIComponent(back);
return url;
}
function statusLine(text) {
@@ -107,6 +144,91 @@ function setDesc(text) {
if (descEl) descEl.textContent = text; // the #doc-modal-desc role=status announcer
}
/* The pending auto-clear (the copy success beat) — the modal's
* page-local copy state (phase 153, task 02). */
let modalCopyTimer = 0;
/* Copy path (phase 153, task 02): the modal's page-local helper
* (the per-page duplication house style — document.js keeps the
* /document.html page's own copy; no new shared module). The
* copied string is `source + "/" + path` (ASSUMPTION A1 — the
* read-tool form; the same two values the meta row renders).
* The clipboard contract is the share-link idiom: writeText in a
* try — a non-secure (http) homelab origin rejects the clipboard,
* so the failure path renders the inline fallback field
* (input-like, prefilled with the full path, selects its full
* content on focus — the .share-link-fallback pattern) + the
* status line "Path ready — copy it from the field." (the
* owner-locked fallback wording, path-adapted) INSIDE
* #doc-modal-meta (the block wraps — no clip rule); success shows
* a transient visible "Path copied." status (auto-clears ~4s, the
* toast idiom — the node is aria-hidden, visual only). The nodes
* land right after the "Copy path" control. #doc-modal-desc
* (setDesc) reads the outcome — one a11y announcer (the share
* pattern: the visible node is aria-hidden). A failed copy NEVER
* clears the previous state into nothing (the old field is
* replaced in place, never dropped); a re-click re-attempts. */
async function copyPathWithFallback(metaEl, path) {
/* A re-click re-attempts: the previous state (the status line
* and/or the fallback field) is replaced in place — a failed
* copy never clears the previous state into nothing (the field
* is swapped for a new one, never dropped). */
metaEl
.querySelectorAll(".doc-copy-status, .doc-path-fallback")
.forEach((el) => el.remove());
let ok = true;
try {
await navigator.clipboard.writeText(path);
} catch {
ok = false;
}
const status = document.createElement("p");
status.className = "doc-copy-status";
status.setAttribute("aria-hidden", "true"); // visual only — #doc-modal-desc is the announcer
status.textContent = ok ? "Path copied." : "Path ready — copy it from the field.";
const btn = metaEl.querySelector(".doc-copy-path");
if (ok) {
if (btn) btn.insertAdjacentElement("afterend", status);
else metaEl.appendChild(status);
setDesc("Path copied.");
clearTimeout(modalCopyTimer);
modalCopyTimer = setTimeout(() => {
if (status.isConnected) status.remove();
}, 4000); // auto-clear ~4s (the toast idiom)
} else {
const field = document.createElement("input");
field.type = "text";
field.readOnly = true;
field.className = "doc-path-fallback";
field.value = path;
field.setAttribute("aria-label", "Document path");
field.title = "Document path — click, then copy (Ctrl/Cmd+C)";
field.addEventListener("focus", () => field.select());
if (btn) {
btn.insertAdjacentElement("afterend", status);
btn.insertAdjacentElement("afterend", field); // field first, status after
} else {
metaEl.append(field, status);
}
setDesc("Path ready — copy it from the field.");
field.focus({ preventScroll: true }); // selects the path — ready to copy
}
return ok;
}
/* Phase 153 (task 02): wire the shared core's "Copy path" control
* (renderDocument built it in the meta block) with the modal's
* page-local helper — the copied string is the same two values the
* meta row renders. */
function wireCopyPath(metaEl, doc) {
const btn = metaEl.querySelector(".doc-copy-path");
if (!btn) return;
btn.addEventListener(
"click",
() => void copyPathWithFallback(metaEl, doc.source + "/" + doc.path),
);
}
function showLoading() {
if (titleEl) titleEl.textContent = "Loading…";
if (metaEl) metaEl.replaceChildren();
@@ -163,13 +285,19 @@ function onModalKeydown(e) {
/* Show the modal for (source, path) and fetch + render the document.
* `trigger` (the chip / table link that was clicked) is remembered so
* closeDocumentModal can return focus to it. */
export function openDocumentModal(source, path, trigger = null) {
* closeDocumentModal can return focus to it. `back` (phase 153, task
* 01) is the origin the "Full page" escape hatch (and the no-skeleton
* fallback) carries to the viewer — the chat call sites pass "/" (the
* shell's home route, the value documentUrl uses); a call site that
* omits it (the Sources table) keeps the URL byte-identical and gets
* the viewer's /sources.html default. */
export function openDocumentModal(source, path, trigger = null, back = null) {
currentBack = back; // reset at the TOP of every open (one-at-a-time)
if (!modalEl || !titleEl || !metaEl || !contentEl) {
// No modal skeleton on this page (should not happen for the pages
// that import this module) — fall back to the dedicated viewer
// instead of swallowing the click.
window.open(fullPageUrl(source, path), "_blank", "noopener");
// instead of swallowing the click (the stored back rides along).
window.open(fullPageUrl(source, path, currentBack), "_blank", "noopener");
return;
}
/* Phase 144 (TODO L6): the modal owns ONE state-only history entry —
@@ -199,7 +327,7 @@ export function openDocumentModal(source, path, trigger = null) {
modalEl.hidden = false; // .doc-modal[hidden] { display:none } — visible now
showLoading();
if (openEl) {
openEl.href = fullPageUrl(source, path); // the "Full page" escape hatch
openEl.href = fullPageUrl(source, path, currentBack); // the "Full page" escape hatch
openEl.hidden = false;
}
document.addEventListener("keydown", onModalKeydown, true); // Escape + Tab trap
@@ -216,6 +344,7 @@ export function openDocumentModal(source, path, trigger = null) {
const doc = await r.json();
if (seq !== fetchSeq) return;
renderDocument(doc, { titleEl, metaEl, contentEl });
wireCopyPath(metaEl, doc); // phase 153 (task 02): the Copy path control
setDesc("Document loaded.");
})
.catch(() => {
+179
View File
@@ -159,6 +159,34 @@
* pages). ONE shared core — the modal AND /document.html get it (no
* per-surface copy); createElement + properties/textContent only (the
* never-build-HTML rule — no innerHTML).
*
* Phase 153 (task 02): the meta row's "Copy path" control — the
* read-tool `source/path` form in the clipboard with one click
* (ASSUMPTION A1 — the exact `source/path` the read tool, the
* chips, and the Sources table use; the paste target is the
* owner's own tooling). The control is built by the ONE shared
* renderDocument core (both surfaces — modal AND /document.html —
* get the SAME icon+text button, AFTER the path code and BEFORE
* the origin anchor), and each surface wires it with its OWN
* page-local clipboard helper (the per-page duplication house style
* — document-modal.js carries the modal's copy; no new shared
* module): navigator.clipboard.writeText in a try — a non-secure
* (http) homelab origin rejects the clipboard, so the failure path
* renders an inline fallback field (input-like, prefilled with the
* path, selects its full content on focus — the .share-link-
* fallback pattern) + the status line (the owner-locked fallback
* wording, path-adapted); success shows a transient VISIBLE status
* (auto-clears ~4s, the toast idiom — the node is aria-hidden,
* visual only) and the surface's role=status live region reads the
* same text (one a11y announcer — the share pattern: #doc-copy-
* announcer on the page, #doc-modal-desc in the modal). A failed
* copy NEVER clears the previous state into nothing (the old state
* is replaced in place, never dropped); a re-click re-attempts.
* The page's status/field float in a .doc-copy-pop block anchored
* under the button (the meta row must never wrap or grow the bar
* — the phase-12/34 contract — so the block is absolutely
* positioned in the header, never in the row); the modal's sit
* inline in the wrapping #doc-modal-meta block.
*/
import { bindSharedHeaderControls, fetchIsAdmin, initSharedHeader } from "./header.js";
@@ -225,6 +253,45 @@ function originAnchor(doc) {
return a;
}
/* Phase 153 (task 02): the meta row's "Copy path" control — the
* icon+text row-action shape (the git-sources.js row-buttons idiom),
* createElementNS-built (the never-build-HTML rule stands — the
* module's ONE innerHTML stays the escape-first markdown render).
* The VISIBLE "Copy path" text IS the accessible name (a redundant
* aria-label is not carried — WCAG 4.1.2 via the text), the icon is
* aria-hidden, and type=button (no form submission). The shared
* renderDocument core builds it (both surfaces — modal AND
* /document.html — get the same shape; each surface wires it with
* its own page-local clipboard helper — the per-page duplication
* house style). */
function copyPathButton() {
const SVG_NS = "http://www.w3.org/2000/svg";
const b = document.createElement("button");
b.type = "button";
b.className = "doc-copy-path";
const icon = document.createElementNS(SVG_NS, "svg");
icon.setAttribute("aria-hidden", "true");
icon.setAttribute("viewBox", "0 0 24 24");
icon.setAttribute("fill", "none");
icon.setAttribute("stroke", "currentColor");
icon.setAttribute("stroke-width", "1.8");
icon.setAttribute("stroke-linecap", "round");
icon.setAttribute("stroke-linejoin", "round");
const backRect = document.createElementNS(SVG_NS, "rect");
backRect.setAttribute("x", "9");
backRect.setAttribute("y", "9");
backRect.setAttribute("width", "11");
backRect.setAttribute("height", "11");
backRect.setAttribute("rx", "2");
const front = document.createElementNS(SVG_NS, "path");
front.setAttribute("d", "M5 15V5a2 2 0 0 1 2-2h10");
icon.append(backRect, front);
const label = document.createElement("span");
label.textContent = "Copy path";
b.append(icon, label);
return b;
}
/* ---------- shared renderer (phase 26, task 02) ----------
* Populates the three elements every render surface provides: a title,
* a .doc-meta badge row (source · format · mono path · created ·
@@ -245,6 +312,14 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
const pathCode = document.createElement("code");
pathCode.className = "doc-path";
pathCode.textContent = doc.path;
// Phase 153 (task 02): the "Copy path" control — the shared core
// builds it (BOTH surfaces — modal AND /document.html — get the
// SAME shape; each surface wires it with its own page-local
// clipboard helper). It lands AFTER the path code and BEFORE the
// origin anchor — one more inline item in the row (the modal's
// wrap rule and the page's clip rule both keep it on their
// shapes).
const copyBtn = copyPathButton();
// Phase 145 (task 03): the "Original" anchor — the git origin as a
// copyable external link, present ONLY when the content payload
// carries origin_url (a git-sourced document's sanitized repository
@@ -258,6 +333,7 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
metaBadge("doc-source-badge", doc.source),
metaBadge("format-badge", doc.format),
pathCode,
copyBtn,
...originItems,
// Phase 106 (task 08, D8): the Created badge BEFORE the Indexed
// one (the owner's verbatim position — the date at the top of a
@@ -835,10 +911,113 @@ if (document.querySelector("#doc-title")) {
: "Back";
}
/* ---------- Copy path (phase 153, task 02) ----------
* The meta row's "Copy path" control (renderDocument — the shared
* core — built it after the path code; this page wires it with
* the page-local helper — the per-page duplication house style:
* document-modal.js carries the modal's own copy, no new shared
* module). The copied string is `source + "/" + path` (ASSUMPTION
* A1 — the exact read-tool form; the same two values the meta row
* renders). navigator.clipboard.writeText in a try — a non-secure
* (http) homelab origin rejects the clipboard, so the failure path
* renders the inline fallback field (input-like, prefilled with
* the full path, selects its full content on focus — the
* .share-link-fallback pattern) + the status line "Path ready —
* copy it from the field." (the owner-locked fallback wording,
* path-adapted). Success: a transient VISIBLE status "Path
* copied." (auto-clears ~4s, the toast idiom — the node is
* aria-hidden, visual only) + the page's #doc-copy-announcer
* (role=status, the visually-hidden node added for this phase) reads
* the same text — one a11y announcer (the share pattern). The
* status/field float in a .doc-copy-pop block anchored under the
* button (the meta row must never wrap or grow the bar — the
* phase-12/34 contract — so the block is absolutely positioned in
* the header, never in the row). A failed copy NEVER clears the
* previous state into nothing (the old block is replaced in
* place); a re-click re-attempts. */
let copyStatusTimer = 0; // the pending auto-clear (the success beat)
/* The inline fallback field (the .share-link-fallback pattern,
* input variant): input-like, prefilled with the full path,
* selects its full content on focus (click or Tab, then
* Ctrl/Cmd+C). readOnly — the field is a copy surface, not an
* editor. value, never innerHTML (the path is data — the XSS
* contract, unchanged). */
function makePathFallbackField(path) {
const field = document.createElement("input");
field.type = "text";
field.readOnly = true;
field.className = "doc-path-fallback";
field.value = path;
field.setAttribute("aria-label", "Document path");
field.title = "Document path — click, then copy (Ctrl/Cmd+C)";
field.addEventListener("focus", () => field.select());
return field;
}
async function copyPathWithFallback(btn, path) {
/* A re-click re-attempts: the previous block (success or
* failure) is replaced in place — a failed copy never clears
* the previous state into nothing (the field is swapped for a
* new one, never dropped). */
document.querySelectorAll(".doc-copy-pop").forEach((el) => el.remove());
let ok = true;
try {
await navigator.clipboard.writeText(path);
} catch {
ok = false;
}
const block = document.createElement("div");
block.className = "doc-copy-pop";
const status = document.createElement("p");
status.className = "doc-copy-status";
status.setAttribute("aria-hidden", "true"); // visual only — the announcer is the role=status node
status.textContent = ok ? "Path copied." : "Path ready — copy it from the field.";
let field = null;
if (ok) {
block.appendChild(status);
} else {
field = makePathFallbackField(path);
block.append(field, status);
}
/* Anchor under the button: the block floats in the header
* (sticky = positioned), so it never wraps the meta row or
* grows the bar (the phase-12/34 contract). */
const header = document.querySelector("header.doc-header");
if (header) {
const br = btn.getBoundingClientRect();
const hr = header.getBoundingClientRect();
block.style.top = `${br.bottom - hr.top + 6}px`;
block.style.left = `${br.left - hr.left}px`;
header.appendChild(block);
}
const announcer = document.querySelector("#doc-copy-announcer");
if (announcer) announcer.textContent = status.textContent;
if (ok) {
clearTimeout(copyStatusTimer);
copyStatusTimer = setTimeout(() => {
if (block.isConnected) block.remove();
}, 4000); // auto-clear ~4s (the toast idiom)
} else if (field) {
field.focus({ preventScroll: true }); // selects the path — ready to copy
}
return ok;
}
/* renderDocument fills the page elements; the page additionally owns
* the document.title (the modal keeps the page title untouched). */
function render(doc) {
renderDocument(doc, { titleEl, metaEl, contentEl });
/* Phase 153 (task 02): wire the shared core's "Copy path"
* control with the page-local helper — the copied string is the
* same two values the meta row renders. */
const copyBtn = metaEl.querySelector(".doc-copy-path");
if (copyBtn) {
copyBtn.addEventListener(
"click",
() => void copyPathWithFallback(copyBtn, doc.source + "/" + doc.path),
);
}
document.title = `${doc.title} · ${brand()}`;
}
+90
View File
@@ -4461,6 +4461,95 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
text-decoration: none;
}
.doc-origin:hover, .doc-origin:focus-visible { text-decoration: underline; }
/* Phase 153 (task 02): the meta row's "Copy path" control — the
row-action language (icon + label, the .git-source-remove idiom)
in a NEUTRAL secondary fill (the .git-source-ignore hover pair —
brand-soft, 12.4:1 on --surface; the meta row's text sits on the
titlebar's --surface). Compact on desktop (the meta row's 0.78rem
scale — a 44px floor would grow the bar); the ≤640px block gives
it the ≥44px touch-target floor (PLAN §7.1). The VISIBLE "Copy
path" text is the accessible name (no aria-label); the icon is
aria-hidden. */
.doc-copy-path {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.12rem 0.5rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-soft); /* 5.1:1 on --surface (the meta row's pair) */
font: inherit;
font-weight: 600;
font-size: 0.78rem;
white-space: nowrap;
cursor: pointer;
}
.doc-copy-path svg { width: 13px; height: 13px; display: block; flex: 0 0 auto; }
.doc-copy-path:hover { background: var(--brand-soft); color: var(--brand-ink); }
.doc-copy-path:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
/* Phase 153 (task 02): the Copy-path status line — the VISIBLE
confirmation (aria-hidden — the surface's role=status live region
is the a11y announcer, the share pattern): the meta row's text
scale, var(--ink-soft) on --surface (5.1:1 — the pair recorded at
:root). Serves BOTH surfaces: the page's floating block and the
modal's wrapping meta block. */
.doc-copy-status {
margin: 0;
font-size: 0.78rem;
color: var(--ink-soft); /* 5.1:1 on --surface */
}
/* Phase 153 (task 02): the /document.html page's Copy-path state
block — a floating panel anchored under the control (absolutely
positioned in the header — the meta row must never wrap or grow
the bar, the phase-12/34 contract, so the state floats OUT of
the row). Surface fill, --line border, the standard shadow;
wraps (the field + the status may share a row or take separate
rows at narrow widths). z-index 30 sits inside the header's own
z-20 root context, so the block floats above the page content. */
.doc-copy-pop {
position: absolute;
z-index: 30;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
max-width: min(36rem, calc(100vw - 2rem));
padding: 0.45rem 0.6rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
}
/* Phase 153 (task 02): the Copy-path inline fallback field — the
.share-link-fallback pattern (input variant): a non-secure (http)
origin rejects the clipboard, so the full path is offered as an
input-like <input> that selects itself on focus — click or Tab,
then Ctrl/Cmd+C. readOnly (a copy surface, not an editor), mono
(the path is data — the .share-link-fallback font), surface
fill, --line border; truncates with an ellipsis at narrow widths
(the selection is the copy). Ink on surface ≈13.8:1 (the
.share-link-fallback pair). */
.doc-path-fallback {
max-width: 26rem;
padding: 0.3rem 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;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.doc-path-fallback:hover { border-color: var(--brand); }
.doc-path-fallback:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
.doc-chunks { font-family: var(--mono); }
/* Phase 106 (task 08, D8): the Created badge — the SAME family as the
Indexed one (no own look: the meta row's text, var(--ink-soft) on
@@ -5552,6 +5641,7 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
#doc-title { font-size: 1.1rem; }
.doc-path { max-width: 16rem; }
.doc-origin { max-width: 16rem; } /* phase 145 (task 03): the Original anchor squeezes like the path */
.doc-copy-path { min-height: 44px; min-width: 44px; } /* phase 153 (task 02): the Copy path control's ≥44px touch-target floor (PLAN §7.1) */
.doc-md { padding: 1.1rem 1rem; }
.doc-summary { padding: 0.75rem 0.9rem; }
.doc-raw { padding: 1rem; font-size: 0.8rem; }
+11
View File
@@ -117,6 +117,17 @@
<div class="doc-title-block">
<h1 id="doc-title">Loading…</h1>
<div id="doc-meta" class="doc-meta"></div>
<!-- Phase 153 (task 02): the Copy-path announcer — the
page's role=status live region for the copy outcome
(visually hidden). The visible status line + fallback
field float in a .doc-copy-pop block (aria-hidden —
visual only, the share pattern): this node is the
sole a11y announcer (no double screen-reader read).
The meta row itself must never grow the bar (the
phase-12/34 contract), so the copy state lives here +
the floating block, not in the row. -->
<p class="visually-hidden" id="doc-copy-announcer" role="status"
aria-live="polite" aria-atomic="true"></p>
</div>
</div>
</div>
+3 -2
View File
@@ -341,10 +341,11 @@ def test_modal_focus_and_a11y(
expect(page.locator("#doc-modal-close")).to_have_attribute("aria-label", "Close document")
# The "Full page" escape hatch is rebuilt to the same encoded viewer
# URL (no back param — the dedicated page's own default applies).
# URL — phase 153 (task 01): the chat-origin open carries its origin
# through (back=/ → &back=%2F; a Sources-origin open omits it).
expect(page.locator("#doc-modal-open")).to_be_visible()
expect(page.locator("#doc-modal-open")).to_have_attribute(
"href", "/document.html?source=docs&path=homelab%2Fkubernetes.md"
"href", "/document.html?source=docs&path=homelab%2Fkubernetes.md&back=%2F"
)
# Closing returns focus to the triggering control.
+486
View File
@@ -0,0 +1,486 @@
"""Phase 153 E2E (Playwright): the in-page modal's "Full page" escape
hatch carries its ORIGIN to the dedicated viewer, and the Copy path
affordance works on BOTH surfaces (the standalone titlebar and the
modal's meta block).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_document_viewer_context.py -v --no-cov
The two user-facing contracts (the phase's completion criteria):
1. **Origin back.** A document opened through the modal reaches the
standalone viewer with the ``back`` context of the page it was
opened FROM — a chat-origin open (the app.js call sites pass
``"/"``) lands on the back link ``href="/"`` labeled "Chat"; a
Sources-origin open (the sources.js call site omits the param —
the URL stays byte-identical to pre-phase) lands on
``href="/sources.html"`` labeled "Sources" (the phase-13
resolution, finally fed on every path). "Full page"
(``#doc-modal-open``, ``target="_blank"`` by design) opens the
dedicated viewer in a NEW TAB — the Playwright context's second
page (the ``expect_popup`` idiom, the phase-36 story-suite
precedent).
2. **Copy path.** The "Copy path" control (``.doc-copy-path`` — the
shared renderDocument core builds it on both surfaces) copies
``source + "/" + path`` (ASSUMPTION A1 — the read-tool form, the
byte-for-byte pin) with one click, with the established
clipboard contract: a granted clipboard (the ``grant_permissions``
idiom — ``http://127.0.0.1`` is a secure context, but headless
Chromium requires the permission grant before ``writeText``
resolves) renders the visible "Path copied." status + the
surface's ``role="status"`` announcer reading the same text; a
rejected clipboard (no grant — the established pattern: the
non-secure/unguarded origin's ``writeText`` rejects, and the
ungranted secure-context API rejects too) renders the inline
fallback field (``.doc-path-fallback`` — input-like, prefilled
with the exact path, SELECTS its full content on focus — the
``.share-link-fallback`` pattern) + the "Path ready — copy it
from the field." status (the owner-locked fallback wording,
path-adapted). Both tests are parameterized over the surface
(standalone / modal — the per-page-duplicated helpers behave
identically by contract).
Regression guards: the DIRECT chip link (the ``documentUrl`` escape
hatch — what a context-menu "open in new tab" reaches; a
middle-click is not a reliable trigger in headless Chromium, so the
contract is asserted on the exact href and verified by navigating to
it — the phase-13 idiom) still carries ``&back=%2F``; and a direct
``/document.html?source=…&path=…`` open without the ``back`` param
still defaults to ``/sources.html`` + "Sources" (the phase-13
fallback).
Determinism: mock-LLM answers only (no ``E2E_REAL_LLM`` dependency),
one fixture document (``docs/homelab/kubernetes.md`` — the copied
string is a byte-for-byte pin), per-module DB truncate/re-import
(the house fixture idiom, the phase-13/26 suites).
Test → contract mapping (Playwright Mapping Rule):
1. ``test_chat_origin_full_page_backs_to_chat`` — the mock's
scripted summary-read flow (the house scripted-turn convention,
phase 119 LOCKED A1 — the read doc is the turn's ONLY chip) →
the chat source chip → the phase-26 same-page modal → "Full
page" (new tab) → the standalone ``#doc-back`` is ``href="/"``
with the "Chat" label (the phase-13 label-match contract, now
actually fed on the modal path).
2. ``test_sources_origin_full_page_backs_to_sources`` — the RAG
folder table's path link (drilled to its level — phase 97) → the
modal → "Full page" (new tab) → the URL carries NO ``back``
param (asserted by absence) and the standalone ``#doc-back`` is
``href="/sources.html"`` with the "Sources" label (the default).
3. ``test_direct_chip_link_still_carries_back`` — the chat chip's
``href`` (the ``documentUrl`` escape hatch) still carries
``&back=%2F`` byte-for-byte; navigating to that exact href
lands on ``href="/"`` + "Chat" (the pre-phase path is untouched).
4. ``test_copy_path_clipboard_success[standalone|modal]`` — the
granted clipboard: the visible "Path copied." status + the
surface's live region read it; the clipboard content is exactly
``docs/homelab/kubernetes.md``.
5. ``test_copy_path_fallback[standalone|modal]`` — the rejected
clipboard: the fallback field appears, prefilled with the exact
path, focused with its full content selected; the status reads
"Path ready — copy it from the field." (+ the live region).
6. ``test_direct_open_without_back_defaults`` — a direct open
without the ``back`` param resolves to ``/sources.html`` +
"Sources" (the phase-13 fallback, regression guard).
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from threading import Thread
from typing import Any
import pytest
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
from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
# The house scripted-turn convention (phase 119, LOCKED A1): the
# scripted summary-read flow reads the kubernetes fixture, so the
# turn's ONLY source chip is the read doc — the citation-bearing
# bubble the chat-origin tests drive.
QUESTION = (
"Read the suggested document: read docs/homelab/kubernetes.md — "
"how is my Kubernetes cluster set up?"
)
# The fixture document every test in this file uses (source=docs) —
# one document, the copied-string pin is byte-for-byte on it.
DOC_SOURCE = "docs"
DOC_PATH = "homelab%2Fkubernetes.md" # percent-encoded (slashes)
DOC_PATH_RAW = "homelab/kubernetes.md"
DOC_TITLE = "Kubernetes Homelab Cluster"
#: The copied string (ASSUMPTION A1 — the read-tool form): the byte
#: for byte pin, pinned against the fixture document.
COPIED_PATH = f"{DOC_SOURCE}/{DOC_PATH_RAW}"
#: The phase-13 label-match contract pins (byte-for-byte).
CHAT_BACK_HREF = "/"
CHAT_BACK_LABEL = "Chat"
SOURCES_BACK_HREF = "/sources.html"
SOURCES_BACK_LABEL = "Sources"
#: The owner-locked copy-path status strings (byte-for-byte — the
#: em dash is U+2014, the share-fallback wording adapted for the
#: path; pinned by the phase-153 unit suite as well).
COPY_SUCCESS = "Path copied."
COPY_FALLBACK = "Path ready — copy it from the field."
#: The chip's escape-hatch href (the documentUrl contract, pre-
#: phase): byte-for-byte, carries back=/ (encoded %2F).
CHIP_HREF = f"/document.html?source={DOC_SOURCE}&path={DOC_PATH}&back=%2F"
#: The Sources row link's escape-hatch href (no back param — the
#: Sources call site omits it; byte-identical to pre-phase).
SOURCES_LINK_HREF = f"/document.html?source={DOC_SOURCE}&path={DOC_PATH}"
#: The modal's "Full page" escape-hatch hrefs (fullPageUrl — task
#: 01): the chat open carries back=/ through; the Sources open
#: omits it (byte-identical to pre-phase).
MODAL_CHAT_FULLPAGE_HREF = CHIP_HREF # the same contract, now true
MODAL_SOURCES_FULLPAGE_HREF = SOURCES_LINK_HREF
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))
def _grant_clipboard(page: Page, app_url: str) -> None:
"""Grant the async-clipboard permissions on the context.
``http://127.0.0.1`` is a secure context, so ``navigator.clipboard``
exists — but headless Chromium still requires the permission grant
before ``writeText`` resolves (without it the fallback branch
fires — the share-chat suite's idiom). A test that does NOT grant
exercises the rejected-clipboard branch deterministically."""
page.context.grant_permissions(
["clipboard-read", "clipboard-write"], origin=app_url
)
def _ask_for_chip(page: Page, app_url: str) -> Any:
"""Drive one scripted chat turn and return the kubernetes.md source
chip (the turn's READ doc — phase 119, LOCKED A1: chips cite read
docs only, so the scripted summary-read flow is what makes the
citation-bearing bubble exist)."""
login(page, app_url, next="/") # phase 79: chat is require_user-gated
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)
# Phase 119 A1: the read doc is the turn's ONLY chip.
expect(page.locator(".msg.brain .source-chip")).to_have_count(1)
return chip
def _drill(page: Page, *names: str) -> None:
"""Phase 97: the catalog is the drill-down tree — click through the
source/folder rows (exact name match) to the level that holds the
asserted file."""
for name in names:
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
def _open_sources_modal(page: Page, app_url: str) -> None:
"""Open the fixture document in the same-page modal through the
Sources path (the sources.js call site — the back param is
omitted, so the "Full page" URL stays byte-identical to
pre-phase)."""
login(page, app_url) # phase 16: the Sources catalog is admin-only
_drill(page, "docs", "homelab")
row = page.locator("#docs-tbody tr", has_text="kubernetes.md")
expect(row).to_have_count(1)
row.locator("td:nth-child(2) a.doc-link").click()
expect(page.locator("#doc-modal")).not_to_have_attribute("hidden")
expect(page.locator("#doc-modal-title")).to_have_text(DOC_TITLE)
# ---------------------------------------------------------------------------
# 1. Chat → chip → modal → "Full page" (new tab) → back returns to Chat
# ---------------------------------------------------------------------------
def test_chat_origin_full_page_backs_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)
chip = _ask_for_chip(page, app_url)
# The pre-phase escape-hatch href is byte-identical (regression
# guard #3 pins the full contract; here it is the contract the
# chat call sites pass `back = "/"` through).
expect(chip.first).to_have_attribute("href", CHIP_HREF)
expect(chip.first).not_to_have_attribute("target") # phase 26: modal, not a new tab
# Phase 26: the left click opens the same-page modal (no new tab).
before = len(page.context.pages)
chip.first.click()
assert len(page.context.pages) == before, "clicking a chip must not open a new tab"
expect(page.locator("#doc-modal-title")).to_have_text(DOC_TITLE)
# Phase 153 (task 01): the "Full page" escape hatch carries the
# open's back (the chat call site passed "/" → &back=%2F).
expect(page.locator("#doc-modal-open")).to_be_visible()
expect(page.locator("#doc-modal-open")).to_have_attribute("href", MODAL_CHAT_FULLPAGE_HREF)
# "Full page" (target=_blank by design) opens the dedicated
# viewer in a NEW TAB — the context's second page.
with page.expect_popup() as popup_info:
page.click("#doc-modal-open")
viewer = popup_info.value
viewer.set_default_timeout(30_000)
# The origin param rode through (encoded %2F — the documentUrl
# contract the modal now shares): the new tab's URL is exactly
# the escape-hatch href, byte-for-byte.
assert viewer.url == app_url + CHIP_HREF, (
f"the new tab's URL is not the escape-hatch href: {viewer.url}"
)
# The standalone viewer rendered the document (not an error state).
expect(viewer.locator("#doc-title")).to_have_text(DOC_TITLE)
# The phase-13 label-match contract, now actually fed on the modal
# path: the back link resolved to the chat page, labeled "Chat".
back = viewer.locator("#doc-back")
expect(back).to_have_attribute("href", CHAT_BACK_HREF)
expect(back).to_have_text(CHAT_BACK_LABEL)
# ---------------------------------------------------------------------------
# 2. Sources → row link → modal → "Full page" (new tab) → back stays on
# Sources (no back param — the default, asserted by ABSENCE)
# ---------------------------------------------------------------------------
def test_sources_origin_full_page_backs_to_sources(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
_open_sources_modal(page, app_url)
# The row link's href carries NO back param (the sources.js call
# site is unchanged — the byte-identity contract).
expect(page.locator("#doc-modal-open")).to_be_visible()
expect(page.locator("#doc-modal-open")).to_have_attribute(
"href", MODAL_SOURCES_FULLPAGE_HREF
)
assert "back=" not in MODAL_SOURCES_FULLPAGE_HREF # the omitted param
# "Full page" opens the dedicated viewer in a NEW TAB.
with page.expect_popup() as popup_info:
page.click("#doc-modal-open")
viewer = popup_info.value
viewer.set_default_timeout(30_000)
# The URL carries NO back param (asserted by absence — the Sources
# path is byte-identical to pre-phase).
assert "back=" not in viewer.url, f"unexpected back param: {viewer.url}"
# The standalone viewer rendered the document (not an error state).
expect(viewer.locator("#doc-title")).to_have_text(DOC_TITLE)
# The back link kept the default target, labeled "Sources".
back = viewer.locator("#doc-back")
expect(back).to_have_attribute("href", SOURCES_BACK_HREF)
expect(back).to_have_text(SOURCES_BACK_LABEL)
# ---------------------------------------------------------------------------
# 3. The direct chip link (the documentUrl escape hatch — what a
# context-menu "open in new tab" reaches) still carries &back=%2F
# ---------------------------------------------------------------------------
def test_direct_chip_link_still_carries_back(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
chip = _ask_for_chip(page, app_url)
# The escape hatch is the chip's href itself (phase 26: the left
# click is intercepted, but the encoded viewer URL survives as the
# no-JS / context-menu "open in new tab" target) — byte-for-byte,
# still carrying back=/ (encoded %2F).
expect(chip.first).to_have_attribute("href", CHIP_HREF)
expect(chip.first).not_to_have_attribute("target")
# A middle-click is not a reliable trigger in headless Chromium,
# so the contract is verified by navigating to the exact href
# (the phase-13 idiom — "the back contract is asserted on that
# exact href and verified by navigating to it directly").
page.goto(app_url + CHIP_HREF)
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE)
back = page.locator("#doc-back")
expect(back).to_have_attribute("href", CHAT_BACK_HREF)
expect(back).to_have_text(CHAT_BACK_LABEL)
# ---------------------------------------------------------------------------
# 4. Copy path — the clipboard SUCCESS branch (parameterized over the
# surface: the standalone titlebar / the modal's meta block)
# ---------------------------------------------------------------------------
def _open_copy_surface(page: Page, app_url: str, surface: str) -> None:
"""Reach the "Copy path" control on the requested surface with the
fixture document rendered."""
if surface == "standalone":
login(page, app_url, next="/") # phase 79: the viewer content is gated
page.goto(
f"{app_url}/document.html?source={DOC_SOURCE}&path={DOC_PATH}"
)
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE)
return
# surface == "modal": the Sources path (the modal renders through
# the ONE shared renderDocument core — the control is the same
# shape; the Sources open keeps the URL byte-identical to
# pre-phase, so this also exercises the default-back surface).
_open_sources_modal(page, app_url)
@pytest.mark.parametrize("surface", ["standalone", "modal"])
def test_copy_path_clipboard_success(
page: Page, app_url: str, mock_llm: int, db_ready: None, surface: str
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
_open_copy_surface(page, app_url, surface)
_grant_clipboard(page, app_url) # the granted-clipboard idiom
copy_btn = page.locator(".doc-copy-path")
expect(copy_btn).to_have_count(1)
copy_btn.click()
if surface == "standalone":
# The visible status (the .doc-copy-pop block — aria-hidden,
# visual only) + the page's live region (the sole announcer).
expect(page.locator(".doc-copy-status")).to_have_text(COPY_SUCCESS, timeout=15_000)
announcer = page.locator("#doc-copy-announcer")
else:
# The modal's nodes sit inline in the wrapping #doc-modal-meta
# block; #doc-modal-desc is the one a11y announcer.
expect(page.locator("#doc-modal-meta .doc-copy-status")).to_have_text(
COPY_SUCCESS, timeout=15_000
)
announcer = page.locator("#doc-modal-desc")
expect(announcer).to_have_attribute("role", "status")
expect(announcer).to_have_text(COPY_SUCCESS)
# No fallback field on the success branch.
expect(page.locator(".doc-path-fallback")).to_have_count(0)
# The clipboard content is EXACTLY source/path (the byte-for-byte
# pin — ASSUMPTION A1, the read-tool form).
copied = page.evaluate("() => navigator.clipboard.readText().then(t => t)")
assert copied == COPIED_PATH, f"clipboard {copied!r} != the pinned {COPIED_PATH!r}"
# ---------------------------------------------------------------------------
# 5. Copy path — the REJECTED-clipboard branch (parameterized over the
# surface): the inline fallback field + the status line
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("surface", ["standalone", "modal"])
def test_copy_path_fallback(
page: Page, app_url: str, mock_llm: int, db_ready: None, surface: str
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
# NO clipboard grant: the established pattern — the ungranted
# origin's writeText rejects, so the failure path renders the
# inline fallback field (deterministic on every origin).
_open_copy_surface(page, app_url, surface)
copy_btn = page.locator(".doc-copy-path")
expect(copy_btn).to_have_count(1)
copy_btn.click()
# The status line (the owner-locked fallback wording, path-
# adapted) is visible + the live region reads it.
if surface == "standalone":
expect(page.locator(".doc-copy-status")).to_have_text(COPY_FALLBACK, timeout=15_000)
expect(page.locator("#doc-copy-announcer")).to_have_text(COPY_FALLBACK)
else:
expect(page.locator("#doc-modal-meta .doc-copy-status")).to_have_text(
COPY_FALLBACK, timeout=15_000
)
expect(page.locator("#doc-modal-desc")).to_have_text(COPY_FALLBACK)
# The inline fallback field (the .share-link-fallback pattern,
# input variant): it appears, is prefilled with the EXACT path
# (the byte-for-byte pin), and is focused with its FULL content
# selected (the select-on-focus contract — the owner can
# Ctrl/Cmd+C directly).
field = page.locator(".doc-path-fallback")
expect(field).to_have_count(1)
expect(field).to_be_visible()
expect(field).to_have_attribute("readonly", "")
expect(field).to_have_value(COPIED_PATH)
state = field.evaluate(
"""(el) => ({
active: el === document.activeElement,
start: el.selectionStart,
end: el.selectionEnd,
length: el.value.length,
})"""
)
assert state["length"] == len(COPIED_PATH), "the field is not prefilled with the path"
assert state["active"], "the field is not focused on the rejected copy"
assert state["start"] == 0 and state["end"] == state["length"], (
f"the field's content is not fully selected: {state}"
)
# A failed copy never claims success: no success status anywhere.
assert COPY_SUCCESS not in page.locator(".doc-copy-status").all_inner_texts()
# ---------------------------------------------------------------------------
# 6. A direct open WITHOUT the back param still defaults to Sources
# (the phase-13 fallback — regression guard)
# ---------------------------------------------------------------------------
def test_direct_open_without_back_defaults(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
login(page, app_url, next="/") # phase 79: the viewer content is gated
page.goto(f"{app_url}/document.html?source={DOC_SOURCE}&path={DOC_PATH}")
assert "back=" not in page.url, f"unexpected back param: {page.url}"
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE)
back = page.locator("#doc-back")
expect(back).to_have_attribute("href", SOURCES_BACK_HREF)
expect(back).to_have_text(SOURCES_BACK_LABEL)
+790 -7
View File
@@ -48,6 +48,30 @@ site):
innerHTML/outerHTML (the module's ONE innerHTML survives), and the
``.doc-origin`` CSS mirroring the ``.doc-path`` ellipsis idiom with
the ``.doc-link`` affordance (+ the ≤640px squeeze).
Phase 153 (task 03) — unit pins for the URL contract and the
copy-path strings (additions beside the task-01/02 pins; nothing
loosened):
* the phase-13 back-resolution contract, guarded BOTH as a byte-for-byte
source pin of the resolution block AND as a node behavioral run of
that exact extracted block (missing / empty / garbage /
protocol-relative / pseudo-protocol ``back`` → ``/sources.html`` +
the "Sources" label; ``/`` → the "Chat" label; any other same-origin
relative → the "Back" label — the phase-13 pins themselves are
untouched);
* the copy-path composition end-to-end under node — the modal's
``wireCopyPath`` click handler and the page's ``render()`` wiring
each send the EXACT ``source + "/" + path`` bytes to the (fake)
clipboard for a source/path pair with spaces + slashes, and the
rejected-clipboard failure prefills the fallback field with that
same composed string (success / failure strings + the prefill,
byte-for-byte);
* the ``document.html`` Copy-path static nodes — the page's
``header.doc-header`` (the ``.doc-copy-pop`` block's anchor —
absent, the float would fall back to the meta row and grow the
bar, the phase-12/34 breach) + the ``#doc-copy-announcer``
(role=status) + the ``#doc-meta`` row the shared core builds the
control into.
"""
from __future__ import annotations
@@ -673,7 +697,7 @@ def test_viewer_url_builder_present_in_chat_and_sources() -> None:
assert 'chip.href = documentUrl(s.source, s.path, "/")' in app_js
assert 'chip.addEventListener("click"' in app_js
assert "e.preventDefault()" in app_js
assert "openDocumentModal(s.source, s.path, chip)" in app_js
assert "openDocumentModal(s.source, s.path, chip, \"/\")" in app_js
sources_js = _read(SOURCES_JS)
# Sources: unchanged 2-arg builder — no back param in the URL.
@@ -694,9 +718,8 @@ def _run_node(script: str) -> str:
def _extract_function(js: str, name: str) -> str:
match = re.search(
rf"(?:export )?function {name}\(source, path(?:, back = \"/\")?\) \{{.*?\n\}}", js, re.S
)
pat = rf"(?:export )?function {name}\(source, path(?:, back = (?:\"/\"|null))?\) \{{.*?\n\}}"
match = re.search(pat, js, re.S)
assert match, f"{name}(source, path) not found"
return match.group(0).replace("export ", "", 1)
@@ -878,14 +901,18 @@ def test_modal_close_contract_pins() -> None:
# The shared renderer (not a copy), called with the modal's own
# #doc-modal-title / #doc-modal-meta / #doc-modal-content elements.
assert "renderDocument(doc, { titleEl, metaEl, contentEl })" in js
assert 'openEl.href = fullPageUrl(source, path)' in js
assert 'openEl.href = fullPageUrl(source, path, currentBack)' in js
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_modal_url_builders_encode_like_the_page() -> None:
"""Behavioral check (node) of the modal's own URL builders: the
content fetch and the "Full page" href must come out percent-encoded
exactly like the page's builders (slashes/spaces in real paths)."""
exactly like the page's builders (slashes/spaces in real paths).
Phase 153 (task 01): the "Full page" builder's optional ``back``
(default null) — a null/absent ``back`` keeps the URL byte-identical
to pre-phase (no ``&back=`` at all — the Sources path), and a truthy
``back`` is appended + percent-encoded (``"/"`` → ``%2F``)."""
js = _read(MODAL_JS)
content_fn = _extract_function(js, "contentUrl")
full_fn = _extract_function(js, "fullPageUrl")
@@ -893,11 +920,18 @@ def test_modal_url_builders_encode_like_the_page() -> None:
content_fn
+ full_fn
+ "\nconsole.log(contentUrl('Homelab', 'notes/my file.yaml'));\n"
+ "console.log(fullPageUrl('H omelab', 'a/b.md'));"
+ "console.log(fullPageUrl('H omelab', 'a/b.md'));\n"
+ "console.log(fullPageUrl('s', 'p'));\n"
+ "console.log(fullPageUrl('s', 'p', '/'));\n"
+ "console.log(fullPageUrl('H omelab', 'a/b.md', '/sources.html'));"
)
assert out.splitlines() == [
"/api/documents/content?source=Homelab&path=notes%2Fmy%20file.yaml",
# pre-phase byte-identical: no ``back`` arg → no ``&back=``
"/document.html?source=H%20omelab&path=a%2Fb.md",
"/document.html?source=s&path=p",
"/document.html?source=s&path=p&back=%2F",
"/document.html?source=H%20omelab&path=a%2Fb.md&back=%2Fsources.html",
]
@@ -920,3 +954,752 @@ def test_viewer_js_rendering_contracts() -> None:
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
def _stmt_block(js: str, start_marker: str, last_stmt: str) -> str:
"""Extract the source of a statement group: from ``start_marker``
to the balanced close of the ``{…}`` opened by ``last_stmt``
(the brace walk is the house style — no third-party parsers)."""
start = js.find(start_marker)
assert start != -1, f"marker not found: {start_marker!r}"
stmt_i = js.find(last_stmt, start)
assert stmt_i != -1, f"statement not found: {last_stmt!r}"
brace_i = js.find("{", stmt_i)
depth = 0
for j in range(brace_i, len(js)):
if js[j] == "{":
depth += 1
elif js[j] == "}":
depth -= 1
if depth == 0:
return js[start : j + 1]
raise AssertionError(f"unbalanced braces from {last_stmt!r}")
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_phase13_back_resolution_guard() -> None:
"""The phase-13 back-resolution contract, guarded in BOTH layers
(task 03; the phase-13 pins in ``test_viewer_js_rendering_
contracts`` are untouched — this runs beside them):
1. SOURCE — the resolution block itself, extracted from the live
``document.js`` and pinned byte-for-byte (the exact statements,
including the two-label ternary);
2. BEHAVIOR — that exact extracted block (not a re-implementation)
run under node over a fake ``params``/``backLink``:
* missing / empty / garbage (absolute cross-origin) /
protocol-relative / pseudo-protocol ``back`` → the
``/sources.html`` default + the "Sources" label;
* ``/`` → the same-origin chat root WINS + the "Chat" label;
* any other same-origin relative → wins verbatim + the
"Back" label (and an explicit ``/sources.html`` keeps the
"Sources" label).
A refactor that re-introduces a browser-history heuristic, an
origin check, or a different label mapping fails the behavior run
(and a reformat of the block fails the byte pin)."""
js = _read(DOCUMENT_JS)
block = _stmt_block(
js,
'const backParam = params.get("back") || "";',
"if (backLabel) {",
)
# 1. the byte-for-byte source pin (the exact statements)
assert 'const backParam = params.get("back") || "";' in block
assert 'backParam.startsWith("/") && !backParam.startsWith("//")' in block
assert "? backParam" in block and ': "/sources.html";' in block
assert "backLink.href = backTarget;" in block
assert 'backTarget === "/"' in block # the label ternary…
assert '"Chat"' in block
assert 'backTarget === "/sources.html"' in block
assert '"Sources"' in block
assert '"Back"' in block # …and the three labels
# 2. the behavior — the EXACT extracted block, run under node
script = (
"const block = " + json.dumps(block) + ";\n"
"function run(v) {\n"
" const params = { get: (k) => (k === 'back' ? v : null) };\n"
" const label = { textContent: '' };\n"
" const backLink = { href: '',"
' querySelector: (s) => (s === "span" ? label : null) };\n'
' const f = new Function("params", "backLink",'
' block + "\\nreturn backTarget;");\n'
" const target = f(params, backLink);\n"
" return [target, label.textContent];\n"
"}\n"
"const cases = [null, '', 'https://evil.com/x', '//evil.com',"
" 'javascript:alert(1)', '/', '/sources.html', '/chat?x=1'];\n"
"for (const v of cases) console.log(JSON.stringify([v, ...run(v)]));\n"
)
out = [json.loads(line) for line in _run_node(script).splitlines()]
assert out == [
[None, "/sources.html", "Sources"], # missing
["", "/sources.html", "Sources"], # empty
["https://evil.com/x", "/sources.html", "Sources"], # garbage (cross-origin)
["//evil.com", "/sources.html", "Sources"], # protocol-relative
["javascript:alert(1)", "/sources.html", "Sources"], # pseudo-protocol
["/", "/", "Chat"], # the chat root wins
["/sources.html", "/sources.html", "Sources"], # explicit, same label
["/chat?x=1", "/chat?x=1", "Back"], # other same-origin relative wins
]
# ---------------------------------------------------------------------------
# Phase 153 (task 02) — "Copy path" (the standalone titlebar meta row +
# the modal meta block — one shared-core control, per-surface wiring)
# ---------------------------------------------------------------------------
DOCUMENT_MODAL_JS = _read(MODAL_JS) # the modal owner (the live asset)
COPY_PATH_STR = 'doc.source + "/" + doc.path' # the copied string (ASSUMPTION A1)
STATUS_TERNARY = (
'status.textContent = ok ? "Path copied." : "Path ready — copy it from the field."'
) # the status wording pair (owner-locked fallback wording, path-adapted)
def _fn_async(js: str, name: str) -> str:
"""Like _fn, but includes a leading ``async `` when the function is
async (the phase-153 helpers are async)."""
start = js.find(f"function {name}(")
assert start != -1, f"{name}() must exist"
if js[max(0, start - 6) : start] == "async ":
start -= 6
i = js.find("(", start)
depth = 0
close = i
while i < len(js):
if js[i] == "(":
depth += 1
elif js[i] == ")":
depth -= 1
if depth == 0:
close = i
break
i += 1
brace = js.find("{", close)
depth = 0
for j in range(brace, len(js)):
if js[j] == "{":
depth += 1
elif js[j] == "}":
depth -= 1
if depth == 0:
return js[start : j + 1]
raise AssertionError(f"unbalanced braces in {name}")
def test_copy_path_button_built_in_the_shared_core() -> None:
"""renderDocument (the ONE shared core — modal AND /document.html):
the Copy-path control joins the meta row AFTER the path code and
BEFORE the origin anchor (one build site — the surfaces can never
drift). The button is a labeled icon+text control: type=button,
class doc-copy-path, an aria-hidden SVG (createElementNS — the
never-build-HTML rule stands: the module's ONE innerHTML stays the
escape-first markdown render) and the VISIBLE "Copy path" text as
the accessible name (no redundant aria-label)."""
fn = _fn(_read(DOCUMENT_JS), "copyPathButton")
assert 'document.createElement("button")' in fn
assert "b.type = \"button\"" in fn
assert 'b.className = "doc-copy-path"' in fn
assert "document.createElementNS(SVG_NS, \"svg\")" in fn
assert 'icon.setAttribute("aria-hidden", "true")' in fn
assert 'label.textContent = "Copy path"' in fn
assert "innerHTML" not in re.sub(r"//.*?$|/\*.*?\*/", "", fn, flags=re.S | re.M)
rd = _fn(_read(DOCUMENT_JS), "renderDocument")
assert "copyPathButton()" in rd
# meta row order: path code → copy button → origin anchor (spread) →
# created badge (the phase-145 pin's slot, unchanged).
code_i = rd.find("pathCode,")
btn_i = rd.find("copyBtn,")
spread_i = rd.find("...originItems,")
created_i = rd.find("metaBadge(\"doc-created\"")
assert 0 <= code_i < btn_i < spread_i < created_i
def test_page_copy_path_helper_pins() -> None:
"""document.js's page-local helper (the per-page duplication house
style — the modal module keeps its own copy; no shared module):
* the copied string is `doc.source + "/" + doc.path` (the read-tool
form — the same two values the meta row renders);
* navigator.clipboard.writeText in a try (a non-secure http origin
rejecting the clipboard is the fallback trigger);
* the status ternary: "Path copied." (success, visual-only —
aria-hidden, the toast idiom) / "Path ready — copy it from the
field." (failure — the owner-locked fallback wording,
path-adapted);
* a re-click re-attempts (the previous .doc-copy-pop block is
removed first — a failed copy never clears the previous state
into nothing);
* the success status auto-clears ~4s (the block is dropped if it
is still connected);
* the page's #doc-copy-announcer (role=status) reads the outcome
(one a11y announcer — the visible node is aria-hidden);
* the fallback field: input-like (type text, readOnly, class
doc-path-fallback, prefilled with the path, selects its content
on focus, auto-focused with preventScroll — the
.share-link-fallback pattern);
* the page's state floats in a .doc-copy-pop block anchored under
the button (in the sticky header — the meta row never wraps or
grows the bar, the phase-12/34 contract)."""
js = _read(DOCUMENT_JS)
assert "async function copyPathWithFallback(btn, path)" in js
assert "function makePathFallbackField(path)" in js
assert 'document.querySelectorAll(".doc-copy-pop").forEach((el) => el.remove());' in js
assert "await navigator.clipboard.writeText(path);" in js
try_i = js.find("try {\n await navigator.clipboard.writeText(path);\n } catch")
assert try_i != -1, "the writeText call must sit in a try (the rejection fallback)"
assert js.count(STATUS_TERNARY) == 1
assert "status.setAttribute(\"aria-hidden\", \"true\")" in js # visual-only status
assert 'document.querySelector("#doc-copy-announcer")' in js
assert "if (block.isConnected) block.remove();" in js
assert "}, 4000);" in js # the auto-clear beat
assert js.count(COPY_PATH_STR) == 1 # the copied string, wired in render()
assert 'metaEl.querySelector(".doc-copy-path")' in js # the render() wiring
# the fallback field (makePathFallbackField)
f = _fn(js, "makePathFallbackField")
assert 'document.createElement("input")' in f
assert "field.type = \"text\"" in f
assert "field.readOnly = true" in f
assert 'field.className = "doc-path-fallback"' in f
assert "field.value = path" in f
assert 'field.setAttribute("aria-label", "Document path")' in f
assert "field.addEventListener(\"focus\", () => field.select())" in f
# the floating block (page surface)
p = _fn_async(js, "copyPathWithFallback")
assert 'block.className = "doc-copy-pop"' in p
assert 'document.querySelector("header.doc-header")' in p
assert "br.bottom - hr.top + 6" in p # anchored under the button
assert "field.focus({ preventScroll: true })" in p
def test_modal_copy_path_helper_pins() -> None:
"""document-modal.js's page-local helper (the per-page duplication
house style):
* the copied string is `doc.source + "/" + doc.path` (the read-tool
form — the same two values the meta row renders);
* writeText in a try; the status ternary (the same owner-locked
wording as the page);
* the nodes land right after the "Copy path" control
(insertAdjacentElement("afterend") — the meta block wraps, so
they may take a new row);
* a re-click re-attempts (the previous .doc-copy-status /
.doc-path-fallback state is removed first — a failed copy never
clears the previous state into nothing);
* success auto-clears ~4s; #doc-modal-desc (setDesc) reads the
outcome (one a11y announcer — the visible node is aria-hidden);
* the renderDocument call is followed by the wireCopyPath wiring
(the control is live on every open)."""
js = DOCUMENT_MODAL_JS
assert "async function copyPathWithFallback(metaEl, path)" in js
assert "function wireCopyPath(metaEl, doc)" in js
assert "let modalCopyTimer = 0" in js
assert "await navigator.clipboard.writeText(path);" in js
assert js.count(STATUS_TERNARY) == 1
assert 'status.setAttribute("aria-hidden", "true")' in js
assert js.count('".doc-copy-status, .doc-path-fallback"') == 1 # the re-attempt cleanup
assert js.count('insertAdjacentElement("afterend"') == 3 # 1 success + 2 failure
assert 'setDesc("Path copied.")' in js
assert 'setDesc("Path ready — copy it from the field.")' in js
assert "if (status.isConnected) status.remove();" in js
assert "}, 4000);" in js
assert js.count(COPY_PATH_STR) == 1 # wired in wireCopyPath
assert 'metaEl.querySelector(".doc-copy-path")' in js
# renderDocument → wireCopyPath, in openDocumentModal's fetch .then
# (the LAST occurrence — the docstring names the render earlier):
rd_i = js.rfind("renderDocument(doc, { titleEl, metaEl, contentEl });")
wire_i = js.rfind("wireCopyPath(metaEl, doc)")
assert rd_i != -1 and wire_i != -1
assert 0 < wire_i - rd_i < 300 # right after the render
# the shared core builds the button for BOTH surfaces — the modal
# module never builds one itself (no per-surface copy):
assert "copyPathButton" not in js
def test_document_html_carries_the_copy_announcer() -> None:
"""The /document.html page's role=status live region for the copy
outcome — visually hidden (the visible status line is the
aria-hidden floating node; this node is the sole a11y announcer,
the share pattern)."""
html = (FRONTEND / "document.html").read_text(encoding="utf-8")
i = html.find('id="doc-copy-announcer"')
assert i != -1
node = html[i - 200 : i + 200]
assert "visually-hidden" in node
assert "role=\"status\"" in node
assert "aria-live" in node
def test_document_html_carries_the_copy_path_static_nodes() -> None:
"""The ``document.html`` Copy-path static nodes (task 03 — the
Copy-path node on the full page): the floating ``.doc-copy-pop``
block is anchored in the page's ``header.doc-header`` (absent,
the page helper's ``if (header)`` guard would drop the block and
the status would never float — the meta row is never grown, the
phase-12/34 contract); the ``#doc-copy-announcer`` (role=status,
the sole a11y announcer) and the ``#doc-meta`` row (where the
shared core builds the control) must all be present for the
page surface to work."""
html = (FRONTEND / "document.html").read_text(encoding="utf-8")
assert '<header class="doc-header">' in html, (
"the page's sticky header is the copy block's anchor"
)
assert 'id="doc-copy-announcer"' in html, "the announcer node is missing"
assert 'role="status"' in html, "the announcer must be a live region"
assert 'id="doc-meta"' in html, "the meta row (the control's home) is missing"
def test_copy_path_css() -> None:
"""The Copy-path styles (phase 153, task 02):
* .doc-copy-path — the row-action language (inline-flex icon +
label), neutral secondary fill, brand-soft hover pair,
focus-visible outline; the ≤640px block gives it the ≥44px
touch-target floor (PLAN §7.1);
* .doc-copy-status — the visible status (ink-soft on surface —
the meta row's 5.1:1 pair), serving BOTH surfaces;
* .doc-copy-pop — the page's floating state block (position
absolute in the sticky header — never grows the bar), z-index
30 inside the header's z-20 root context;
* .doc-path-fallback — the .share-link-fallback pattern (input
variant): mono, ellipsis, surface fill, --line border."""
css = (FRONTEND / "assets" / "styles.css").read_text(encoding="utf-8")
bp = css.find(".doc-copy-path {")
assert bp != -1
b = css[bp : css.find("}", bp)]
assert "display: inline-flex" in b
assert "gap: 0.3rem" in b
assert "cursor: pointer" in b
assert ".doc-copy-path svg" in css
assert ".doc-copy-path:hover" in css
assert ".doc-copy-path:focus-visible" in css
assert ".doc-copy-path { min-height: 44px; min-width: 44px; }" in css # mobile floor
sp = css.find(".doc-copy-status {")
assert sp != -1
s = css[sp : css.find("}", sp)]
assert "color: var(--ink-soft)" in s
pp = css.find(".doc-copy-pop {")
assert pp != -1
p = css[pp : css.find("}", pp)]
assert "position: absolute" in p
assert "z-index: 30" in p
fp = css.find(".doc-path-fallback {")
assert fp != -1
f = css[fp : css.find("}", fp)]
assert "font-family: var(--mono)" in f
assert "text-overflow: ellipsis" in f
assert ".doc-path-fallback:hover" in css
assert ".doc-path-fallback:focus-visible" in css
def _copy_path_page_fixture() -> str:
"""The shared node fixture: a fake DOM (elements + query registry +
tracked pop blocks) + a fake clipboard (resolves or rejects) + a
fake timer (captures the fn + delay)."""
return """
function makeEl(tag) {
const el = {
tag, children: [], attrs: {}, style: {},
className: "", type: "", readOnly: false, value: "", title: "",
isConnected: true, focusCount: 0, selectCount: 0, listeners: {},
setAttribute(k, v) { this.attrs[k] = v; },
appendChild(c) { this.children.push(c); return c; },
append(...cs) { this.children.push(...cs); },
insertAdjacentElement(pos, node) {
if (pos === "afterend") this.after.unshift(node);
return node;
},
addEventListener(ev, fn) { (this.listeners[ev] = this.listeners[ev] || []).push(fn); },
focus() { this.focusCount += 1; (this.listeners.focus || []).forEach((f) => f()); },
select() { this.selectCount += 1; },
remove() { this.isConnected = false; },
getBoundingClientRect() {
return { top: 10, left: 20, bottom: 44, right: 90, width: 70, height: 34 };
},
};
el.after = [];
return el;
}
const created = [];
const registry = {};
let written = [];
let reject = false;
let timerFn = null;
let timerDelay = null;
globalThis.setTimeout = (fn, delay) => { timerFn = fn; timerDelay = delay; return 1; };
globalThis.clearTimeout = () => {};
// Node 24's globalThis.navigator is GETTER-ONLY (a plain assignment
// silently fails in sloppy mode) — defineProperty is the only way to
// stand up the fake clipboard.
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
clipboard: {
writeText: async (s) => { written.push(s); if (reject) throw new Error("denied"); },
},
},
});
globalThis.document = {
createElement: (t) => { const el = makeEl(t); created.push(el); return el; },
createElementNS: (ns, t) => makeEl(t),
querySelector: (sel) => {
if (!registry[sel]) registry[sel] = makeEl("root:" + sel);
return registry[sel];
},
querySelectorAll: (sel) =>
sel === ".doc-copy-pop"
? created.filter((c) => c.className === "doc-copy-pop" && c.isConnected)
: [],
};
let copyStatusTimer = 0;
"""
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_page_copy_path_behavior_under_node() -> None:
"""The page's copyPathWithFallback under node (a fake DOM + a fake
clipboard):
* success — the clipboard receives the EXACT `source + "/" + path`
string (spaces + slashes intact — the read-tool form,
ASSUMPTION A1), the visible status is "Path copied." (the
aria-hidden node), the announcer reads the same text, the block
is anchored under the button in the header (top/left computed
from the rects), and the auto-clear timer is scheduled at 4000
ms and drops the block when it fires;
* failure — no write, the pre-filled readOnly fallback field
(doc-path-fallback, aria-labelled "Document path") is
auto-focused and selects its content on focus, the status reads
"Path ready — copy it from the field." (the owner-locked
wording, path-adapted) and the announcer reads it too;
* a re-click re-attempts (failure again → exactly one connected
state block; then success → the field is replaced by the
success status — never cleared into nothing)."""
js = _copy_path_page_fixture()
js += _fn(_read(DOCUMENT_JS), "makePathFallbackField")
js += "\n" + _fn_async(_read(DOCUMENT_JS), "copyPathWithFallback")
js += """
async function main() {
let out = {};
const header = document.querySelector("header.doc-header");
const btn = document.createElement("button");
const ok1 = await copyPathWithFallback(btn, "H omelab/a/b c.md");
const pops = () => created.filter((c) => c.className === "doc-copy-pop");
const pop1 = pops()[pops().length - 1];
out = {
ok1,
written: written.slice(),
popClass: pop1.className,
inHeader: header.children.includes(pop1),
top: pop1.style.top,
left: pop1.style.left,
statusText: pop1.children.find((c) => c.className === "doc-copy-status").textContent,
statusAria: pop1.children.find((c) => c.className === "doc-copy-status").attrs["aria-hidden"],
announcer: registry["#doc-copy-announcer"].textContent,
timerDelay,
connected: pops().filter((p) => p.isConnected).length,
};
timerFn(); // the auto-clear fires
out.cleared = !pop1.isConnected;
reject = true;
const ok2 = await copyPathWithFallback(btn, "H omelab/a/b c.md");
const pop2 = pops().filter((p) => p.isConnected)[0];
const field = pop2.children.find((c) => c.tag === "input");
out.ok2 = ok2;
out.fieldValue = field.value;
out.fieldType = field.type;
out.fieldReadOnly = field.readOnly;
out.fieldClass = field.className;
out.fieldAria = field.attrs["aria-label"];
out.fieldTitle = field.title;
out.fieldFocus = field.focusCount;
out.fieldSelect = field.selectCount;
out.failStatus = pop2.children.find((c) => c.className === "doc-copy-status").textContent;
out.failAnnouncer = registry["#doc-copy-announcer"].textContent;
out.connectedAfterFail = pops().filter((p) => p.isConnected).length;
await copyPathWithFallback(btn, "H omelab/a/b c.md"); // re-attempt (fail again)
out.connectedAfterRetry = pops().filter((p) => p.isConnected).length;
reject = false;
const ok3 = await copyPathWithFallback(btn, "x/y.md"); // re-attempt (success)
out.ok3 = ok3;
out.lastWritten = written[written.length - 1];
const pop3 = pops().filter((p) => p.isConnected)[0];
out.successReplacesField = !pop3.children.some((c) => c.tag === "input");
console.log(JSON.stringify(out));
}
main().catch((e) => { console.error("FAIL " + e.message); process.exit(1); });
"""
r = _run_node(js)
out = json.loads(r)
assert out["ok1"] is True
assert out["written"] == ["H omelab/a/b c.md"] # the exact source/path form
assert out["popClass"] == "doc-copy-pop"
assert out["inHeader"] is True
assert out["top"] == "40px" and out["left"] == "0px" # anchored under the button
assert out["statusText"] == "Path copied."
assert out["statusAria"] == "true" # visual only
assert out["announcer"] == "Path copied." # the a11y region
assert out["timerDelay"] == 4000
assert out["connected"] == 1
assert out["cleared"] is True # the auto-clear dropped the block
assert out["ok2"] is False
assert out["fieldValue"] == "H omelab/a/b c.md" # prefilled with the path
assert out["fieldType"] == "text"
assert out["fieldReadOnly"] is True
assert out["fieldClass"] == "doc-path-fallback"
assert out["fieldAria"] == "Document path"
assert out["fieldTitle"] == "Document path — click, then copy (Ctrl/Cmd+C)"
assert out["fieldFocus"] == 1 # auto-focused (selects the path)
assert out["fieldSelect"] == 1 # the focus listener selected the content
assert out["failStatus"] == "Path ready — copy it from the field."
assert out["failAnnouncer"] == "Path ready — copy it from the field."
assert out["connectedAfterFail"] == 1
assert out["connectedAfterRetry"] == 1 # the re-click replaced, never cleared
assert out["ok3"] is True
assert out["lastWritten"] == "x/y.md"
assert out["successReplacesField"] is True # the field was swapped for the status
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_modal_copy_path_behavior_under_node() -> None:
"""The modal's copyPathWithFallback under node (a fake DOM + a
fake clipboard + the setDesc announcer):
* success — the clipboard receives the EXACT `source + "/" + path`
string, the "Path copied." status lands right after the control
(insertAdjacentElement("afterend")), the visible node is
aria-hidden, #doc-modal-desc reads the outcome, and the
auto-clear timer (4000 ms) drops the status when it fires;
* failure — the pre-filled readOnly fallback field + the
"Path ready — copy it from the field." status land in DOM order
[field, status] right after the control (the meta block wraps),
the field is auto-focused and selects its content on focus, and
#doc-modal-desc reads the outcome;
* a re-click re-attempts (the previous state is removed first —
exactly one connected state pair after the retry — a failed
copy never clears the previous state into nothing)."""
js = """
""" + _copy_path_page_fixture()
js += """
const desc = [];
function setDesc(t) { desc.push(t); }
let modalCopyTimer = 0;
"""
js += "\n" + _fn_async(DOCUMENT_MODAL_JS, "copyPathWithFallback")
js += """
async function main() {
let out = {};
const inserted = [];
const btn = makeEl("button");
btn.className = "doc-copy-path";
btn.insertAdjacentElement = function (pos, node) {
if (pos === "afterend") { this.after.unshift(node); inserted.push(node); }
return node;
};
const metaEl = {
appendChild(c) { inserted.push(c); return c; },
append(...cs) { inserted.push(...cs); },
querySelector(sel) { return sel === ".doc-copy-path" ? btn : null; },
querySelectorAll() { return inserted; },
};
const ok1 = await copyPathWithFallback(metaEl, "H omelab/a/b c.md");
const live = () =>
btn.after.filter(
(n) =>
(n.className === "doc-copy-status" || n.className === "doc-path-fallback") &&
n.isConnected,
);
out = {
ok1,
written: written.slice(),
after: live().map((n) => n.className),
statusText: live().find((n) => n.className === "doc-copy-status").textContent,
statusAria: live().find((n) => n.className === "doc-copy-status").attrs["aria-hidden"],
desc: desc.slice(),
timerDelay,
};
timerFn(); // the auto-clear fires
out.cleared = !btn.after[0].isConnected;
reject = true;
const ok2 = await copyPathWithFallback(metaEl, "H omelab/a/b c.md");
const lv = live();
const field = lv.find((n) => n.className === "doc-path-fallback");
out.ok2 = ok2;
out.afterFail = lv.map((n) => n.className);
out.fieldValue = field.value;
out.fieldType = field.type;
out.fieldReadOnly = field.readOnly;
out.fieldAria = field.attrs["aria-label"];
out.fieldFocus = field.focusCount;
out.fieldSelect = field.selectCount;
out.failStatus = lv.find((n) => n.className === "doc-copy-status").textContent;
out.descFail = desc[desc.length - 1];
out.connectedAfterFail = lv.length;
await copyPathWithFallback(metaEl, "H omelab/a/b c.md"); // re-attempt (fail again)
out.connectedAfterRetry = live().length;
console.log(JSON.stringify(out));
}
main().catch((e) => { console.error("FAIL " + e.message); process.exit(1); });
"""
r = _run_node(js)
out = json.loads(r)
assert out["ok1"] is True
assert out["written"] == ["H omelab/a/b c.md"] # the exact source/path form
assert out["after"] == ["doc-copy-status"] # right after the control
assert out["statusText"] == "Path copied."
assert out["statusAria"] == "true" # visual only
assert out["desc"] == ["Path copied."] # the a11y region (setDesc)
assert out["timerDelay"] == 4000
assert out["cleared"] is True # the auto-clear dropped the status
assert out["ok2"] is False
assert out["afterFail"] == ["doc-path-fallback", "doc-copy-status"] # field first, status after
assert out["fieldValue"] == "H omelab/a/b c.md" # prefilled with the path
assert out["fieldType"] == "text"
assert out["fieldReadOnly"] is True
assert out["fieldAria"] == "Document path"
assert out["fieldFocus"] == 1 # auto-focused (selects the path)
assert out["fieldSelect"] == 1 # the focus listener selected the content
assert out["failStatus"] == "Path ready — copy it from the field."
assert out["descFail"] == "Path ready — copy it from the field."
assert out["connectedAfterFail"] == 2 # one field + one status
assert out["connectedAfterRetry"] == 2 # the re-click replaced, never cleared
# ---------------------------------------------------------------------------
# Phase 153 (task 03) — the copy-path composition, end-to-end under node
# (the click handler + render() wiring → the EXACT composed bytes on the
# (fake) clipboard; the pre-existing pins above call the helper with a
# pre-composed string — these run the composition step itself)
# ---------------------------------------------------------------------------
PUMP = "async function pump() { for (let i = 0; i < 300; i++) await Promise.resolve(); }\n"
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_wire_copy_path_click_composes_the_exact_source_path_string() -> None:
"""The modal's ``wireCopyPath`` click handler, executed end-to-end
under node (the REAL ``wireCopyPath`` + the REAL ``
copyPathWithFallback`` from the live ``document-modal.js``, the
DOM/clipboard/timers faked): for ``source = "H omelab"`` (space) +
``path = "a/b c.md"`` (slashes + a space), a click sends the
EXACT ``"H omelab/a/b c.md"`` bytes to the clipboard (byte-for-
byte — a refactor that reorders, re-encodes, or trims the
composition breaks the exact string). The success beat announces
the exact success string; a REJECTED clipboard then prefills the
fallback field with that SAME composed string (the exact prefill,
readOnly, focused + selected — the "never clears the previous
state" re-click stands: the second status replaces the first)."""
modal_js = _read(MODAL_JS)
wire = _fn(modal_js, "wireCopyPath")
helper = _fn_async(modal_js, "copyPathWithFallback")
script = (
_copy_path_page_fixture()
+ "\nlet modalCopyTimer = 0;\n"
+ "const descCalls = [];\n"
+ "function setDesc(t) { descCalls.push(t); }\n"
+ "let createdInput = null;\n"
+ "document.createElement = (t) => { const el = makeEl(t);"
+ " if (t === 'input') createdInput = el; return el; };\n"
+ "const btn = makeEl('button');\n"
+ "const metaEl = { querySelector: (s) => (s === '.doc-copy-path' ? btn : null),"
+ " querySelectorAll: () => [], replaceChildren() {} };\n"
+ helper
+ "\n" + wire
+ "\n" + PUMP
+ "wireCopyPath(metaEl, { source: 'H omelab', path: 'a/b c.md' });\n"
+ "const newestStatus = () => (btn.after.find((e) => e.className === 'doc-copy-status')"
+ " || { textContent: undefined }).textContent;\n"
+ "const click = btn.listeners.click[0];\n"
+ "click(); await pump();\n"
+ "const okOut = { written: written.slice(), status: newestStatus(),"
+ " descCalls: descCalls.slice() };\n"
+ "reject = true; click(); await pump();\n"
+ "const failOut = { input: createdInput && createdInput.value,"
+ " readOnly: createdInput && createdInput.readOnly,"
+ " focusCount: createdInput && createdInput.listeners.focus.length,"
+ " selectCount: createdInput && createdInput.selectCount,"
+ " status: newestStatus() };\n"
+ "console.log(JSON.stringify({ okOut, failOut }));\n"
)
out = json.loads(_run_node(script))
# the exact composed bytes (spaces + slashes) on the clipboard
assert out["okOut"]["written"] == ["H omelab/a/b c.md"], (
"the click handler must send the exact source + '/' + path bytes"
)
assert out["okOut"]["status"] == "Path copied."
assert out["okOut"]["descCalls"] == ["Path copied."]
# the rejected-clipboard failure: the prefill is the SAME composed string
assert out["failOut"]["input"] == "H omelab/a/b c.md", (
"the fallback field must prefill with the exact composed string"
)
assert out["failOut"]["readOnly"] is True
assert out["failOut"]["focusCount"] == 1
assert out["failOut"]["selectCount"] == 1
assert out["failOut"]["status"] == "Path ready — copy it from the field."
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_page_render_wires_the_copy_path_with_the_composed_string() -> None:
"""The page's ``render()`` wiring, executed end-to-end under node
(the REAL ``render`` + the page-local ``copyPathWithFallback`` +
``makePathFallbackField`` from the live ``document.js``, the
DOM/clipboard/timers faked): a click on the shared core's
``.doc-copy-path`` control sends the EXACT ``"H omelab/a/b c.md"``
bytes to the clipboard (the ``doc.source + "/" + doc.path``
composition, byte-for-byte). The success beat floats the status in
the ``header.doc-header`` block (the meta row is never grown —
the phase-12/34 contract); the REJECTED-clipboard failure floats
the fallback field prefilled with that SAME composed string
(readOnly, focused + selected) beside the exact failure status, and
the role=status announcer mirror gets the same text."""
js = _read(DOCUMENT_JS)
render_fn = _fn(js, "render")
helper = _fn_async(js, "copyPathWithFallback")
field_fn = _fn(js, "makePathFallbackField")
script = (
_copy_path_page_fixture()
+ "const brand = () => 'Brain of Reese';\n"
+ "const rendered = [];\n"
+ "function renderDocument(d, els) { rendered.push(d); }\n"
+ "const btn = makeEl('button');\n"
+ "const titleEl = {};\n"
+ "const metaEl = { querySelector: (s) => (s === '.doc-copy-path' ? btn : null),"
+ " replaceChildren() {} };\n"
+ "const contentEl = {};\n"
+ "const header = document.querySelector('header.doc-header');\n"
+ field_fn
+ "\n" + helper
+ "\n" + render_fn
+ "\n" + PUMP
+ "render({ source: 'H omelab', path: 'a/b c.md', title: 'T' });\n"
+ "const click = btn.listeners.click[0];\n"
+ "click(); await pump();\n"
+ "const okBlock = header.children[0];\n"
+ "const okOut = { written: written.slice(), blockChildren: okBlock.children.length,"
+ " status: okBlock.children[0].textContent,"
+ " announcer: document.querySelector('#doc-copy-announcer').textContent };\n"
+ "reject = true; click(); await pump();\n"
+ "const failBlock = header.children[header.children.length - 1];\n"
+ "const field = failBlock.children[0]; const failStatus = failBlock.children[1];\n"
+ "const failOut = { value: field.value, readOnly: field.readOnly,"
+ " focusCount: field.listeners.focus.length, selectCount: field.selectCount,"
+ " status: failStatus.textContent, className: failBlock.className };\n"
+ "console.log(JSON.stringify({ okOut, failOut }));\n"
)
out = json.loads(_run_node(script))
# the exact composed bytes (spaces + slashes) on the clipboard
assert out["okOut"]["written"] == ["H omelab/a/b c.md"], (
"render() must wire the click to the exact source + '/' + path bytes"
)
# the float lives in the header (never the meta row): one status child
assert out["okOut"]["blockChildren"] == 1
assert out["okOut"]["status"] == "Path copied."
assert out["okOut"]["announcer"] == "Path copied." # the a11y mirror
# the rejected-clipboard failure: the prefill is the SAME composed string
assert out["failOut"]["value"] == "H omelab/a/b c.md", (
"the page fallback field must prefill with the exact composed string"
)
assert out["failOut"]["readOnly"] is True
assert out["failOut"]["focusCount"] == 1
assert out["failOut"]["selectCount"] == 1
assert out["failOut"]["status"] == "Path ready — copy it from the field."
assert out["failOut"]["className"] == "doc-copy-pop" # the floating block
+260 -1
View File
@@ -32,15 +32,56 @@ source-level contract a silent regression would break:
``closingViaBack``-wrapped close in the back branch);
* a fallback-path push (the ``window.open`` no-skeleton fallback's
early ``return`` precedes the first ``history.`` use in the open).
Phase 153 (task 01) — the "Full page" escape hatch carries the origin
``back``; the module's added pins:
* a second ``currentBack`` declaration / a reset that does not land at
the top of EVERY open (before the no-skeleton guard — the fallback
path reads the stored value);
* ``fullPageUrl`` appending ``&back=`` unconditionally (a null/absent
``back`` must keep the URL byte-identical — the Sources path);
* the ``openDocumentModal`` ``back`` default drifting from ``null``
(the Sources table's 3-arg call must stay byte-identical);
* a ``fullPageUrl`` call site dropping the stored ``back`` (the
no-skeleton fallback and the anchor must both read ``currentBack``).
Phase 153 (task 03) — unit pins for the URL contract (additions on top
of task 01's source pins; the pre-phase contract is the load-bearing
byte-identity):
* ``fullPageUrl`` behavior under node — ``back = null`` and an ABSENT
third arg are byte-identical to each other AND to the exact
pre-phase string (no ``&back=`` anywhere — the Sources path);
``back = "/"`` appends exactly ``&back=%2F`` to that same string;
* ``openDocumentModal`` behavior under node — the no-skeleton
``window.open`` fallback carries the stored ``back`` (a chat open's
``"/"`` lands in the URL, an omitted ``back`` keeps it byte-
identical) and the ``#doc-modal-open`` ("Full page") anchor carries
it on a skeleton open (chat → ``&back=%2F``; a Sources-style open
→ byte-identical, no param).
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
MODAL_JS = FRONTEND / "assets" / "document-modal.js"
HAVE_NODE = shutil.which("node") is not None
def _run_node(script: str) -> str:
proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60)
assert proc.returncode == 0, f"node failed: {proc.stderr}"
return proc.stdout
def _js() -> str:
return MODAL_JS.read_text(encoding="utf-8")
@@ -168,7 +209,9 @@ def test_window_open_fallback_precedes_the_entry_logic() -> None:
``return`` precedes the first ``history.`` use in the open."""
open_body = _fn(_js(), "openDocumentModal")
guard_i = open_body.find("if (!modalEl || !titleEl || !metaEl || !contentEl) {")
open_i = open_body.find('window.open(fullPageUrl(source, path), "_blank", "noopener");')
open_i = open_body.find(
'window.open(fullPageUrl(source, path, currentBack), "_blank", "noopener");'
)
ret_i = open_body.find("return;", open_i)
first_hist_i = open_body.find("history.")
assert -1 < guard_i < open_i < ret_i < first_hist_i, (
@@ -358,3 +401,219 @@ def test_pre_phase_contract_pins_still_hold() -> None:
)
assert "triggerEl.focus({ preventScroll: true });" in close_body
assert "triggerEl = null;" in close_body
# ---------- phase 153 (task 01): the "Full page" hatch carries the origin ----------
def test_currentback_declared_exactly_once_and_reset_at_the_top_of_every_open() -> None:
"""REGRESSION: a second ``currentBack`` writer, or a reset that
does not land at the top of EVERY open. ``currentBack`` is
declared by EXACTLY ONE ``let``, and the open resets it
(``currentBack = back;``) BEFORE the no-skeleton guard — so even
the ``window.open`` fallback path (which early-returns there)
reads the stored value of THIS open."""
js = _js()
assert js.count("let currentBack = null;") == 1
assert len(re.findall(r"let\s+currentBack\s*=", js)) == 1
open_body = _fn(js, "openDocumentModal")
reset_i = open_body.find("currentBack = back;")
guard_i = open_body.find("if (!modalEl || !titleEl || !metaEl || !contentEl) {")
assert -1 < reset_i < guard_i, (
"the reset must land at the TOP of every open — before the "
"no-skeleton guard (the fallback path reads the stored value)"
)
# Exactly one reset site (no second writer anywhere in the module).
assert js.count("currentBack = back;") == 1
def test_fullpageurl_appends_encoded_back_only_when_truthy() -> None:
"""REGRESSION: the ``back`` param leaking into a null/absent open
(the Sources path must stay byte-identical — the viewer's
/sources.html default) or the encoding drifting from the
app.js::documentUrl contract. The signature is
``fullPageUrl(source, path, back = null)`` and the ONLY writer
after the base URL is the guarded ``if (back)`` append —
percent-encoded, exactly the way documentUrl documents it."""
js = _js()
assert "function fullPageUrl(source, path, back = null)" in js
fn = _fn(js, "fullPageUrl")
assert 'if (back) url += "&back=" + encodeURIComponent(back);' in fn
# The base URL is built first; the guarded append is the only
# ``&back=`` writer (a null back bakes no param into the URL).
base_i = fn.find('"/document.html?source=" + encodeURIComponent(')
back_i = fn.find('"&back=" + encodeURIComponent(back)', base_i)
assert -1 < base_i < back_i, "the back append must follow the base URL"
base_part = fn.split('"&back="')[0]
assert "back=" not in base_part, (
"no &back= may be baked into the base URL (null back = no param)"
)
def test_open_document_modal_defaults_back_to_null() -> None:
"""REGRESSION: a non-null default for the ``back`` argument.
``openDocumentModal(source, path, trigger = null, back = null)`` —
a call WITHOUT ``back`` (the Sources table's
``openDocumentModal(d.source, d.path, link)``) must leave the
"Full page" URL byte-identical to pre-phase (the viewer's
/sources.html default — the byte-identity is the contract)."""
js = _js()
assert (
"export function openDocumentModal(source, path, trigger = null, back = null)"
in js
)
def test_fallback_and_anchor_both_read_the_stored_back() -> None:
"""REGRESSION: a call site passing ``back`` positionally from a
caller, or dropping the third argument (a dropped arg loses the
origin — the chat path would land on "/sources.html"). BOTH the
no-skeleton ``window.open`` fallback and the ``#doc-modal-open``
("Full page") anchor read ``currentBack``: exactly three
``fullPageUrl(`` sites in the module (one definition + two call
sites), and no call site without the stored variable."""
js = _js()
assert 'window.open(fullPageUrl(source, path, currentBack), "_blank", "noopener");' in js
assert "openEl.href = fullPageUrl(source, path, currentBack);" in js
assert "fullPageUrl(source, path)" not in js, "a dropped third argument loses the back"
assert "fullPageUrl(source, path, back)" not in js, ("call sites must read the stored variable")
assert js.count("fullPageUrl(") == 3, "1 definition + 2 call sites"
# ---------- phase 153 (task 03): the URL contract, behavior-pinned under node ----------
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_fullpageurl_null_back_is_byte_identical_to_the_absent_and_pre_phase() -> None:
"""The URL contract's load-bearing byte-identity, executed under
node (the real function, not a re-implementation):
* ``back = null`` and an ABSENT third argument produce the same
string, and that string is byte-identical to the EXACT pre-phase
string (no ``&back=`` anywhere — the Sources path must never gain
the param);
* ``back = "/"`` (a chat open) appends exactly ``&back=%2F`` to
that same pre-phase string (the encoding pinned byte-for-byte).
A refactor that bakes a ``back=`` into the base, reorders the
param, or re-encodes the values breaks one of the exact strings."""
fn = _fn(_js(), "fullPageUrl")
out = _run_node(
fn
+ "\nconsole.log(fullPageUrl('H omelab', 'a/b.md'));"
+ "\nconsole.log(fullPageUrl('H omelab', 'a/b.md', null));"
+ "\nconsole.log(fullPageUrl('H omelab', 'a/b.md', '/'));"
)
absent, null_back, chat = out.splitlines()
pre_phase = "/document.html?source=H%20omelab&path=a%2Fb.md"
assert null_back == absent, "back = null must be byte-identical to an absent back"
assert absent == pre_phase, (
"a null/absent back must keep the URL byte-identical to pre-phase"
)
assert "back=" not in absent, "no &back= may be present for a null/absent back"
assert chat == pre_phase + "&back=%2F", (
"back = '/' must append exactly &back=%2F to the pre-phase string"
)
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_open_document_modal_no_skeleton_fallback_carries_the_stored_back() -> None:
"""The no-skeleton ``window.open`` fallback, executed under node
(the real ``openDocumentModal`` with the module state stubbed):
a chat open (``back = "/"``) lands in the URL exactly (``%2F``),
and a Sources-style open (``back`` omitted — the 3-arg call)
keeps the URL byte-identical (no ``&back=`` at all). The
fallback is the ``window.open(url, "_blank", "noopener")`` shape,
and the early ``return`` means neither open touches the
history."""
fn = _fn(_js(), "openDocumentModal")
url_fn = _fn(_js(), "fullPageUrl")
script = (
"let currentBack = null;\n"
"const modalEl = null, titleEl = null, metaEl = null, contentEl = null, openEl = null;\n"
"const opened = [];\n"
"globalThis.window = { open: (u, t, f) => opened.push([u, t, f]) };\n"
+ url_fn
+ "\n"
+ fn
+ "\nopenDocumentModal('H omelab', 'a/b c.md', null, '/');\n"
+ "\nopenDocumentModal('S2', 'p2.md', null);\n"
+ "\nconsole.log(JSON.stringify(opened));\n"
)
out = json.loads(_run_node(script))
assert out == [
[
"/document.html?source=H%20omelab&path=a%2Fb%20c.md&back=%2F",
"_blank",
"noopener",
],
# the omitted back (the Sources table's 3-arg call) stays byte-identical
["/document.html?source=S2&path=p2.md", "_blank", "noopener"],
]
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_open_document_modal_full_page_anchor_carries_the_stored_back() -> None:
"""The ``#doc-modal-open`` ("Full page") anchor on a skeleton
open, executed under node (the real ``openDocumentModal`` with the
modal elements + history + fetch chain stubbed): a chat open
(``back = "/"``) sets the anchor's ``href`` to the pre-phase string
+ exactly ``&back=%2F``; a follow-up Sources-style open (``back``
omitted — the defensive re-open-while-open branch, which updates
the SAME anchor) resets it to the byte-identical no-param string.
The open's first push is a single state-only entry (the phase-144
contract stands); the defensive re-open replaces in place (no
second push)."""
fn = _fn(_js(), "openDocumentModal")
url_fn = _fn(_js(), "fullPageUrl")
script = (
"let currentBack = null;\n"
"let triggerEl = null;\n"
"let fetchSeq = 0;\n"
"let entry = null;\n"
"let lastPrev = null;\n"
"let closingViaBack = false;\n"
"const modalEl = { hidden: true };\n"
"const titleEl = {}, metaEl = { replaceChildren() {} };\n"
"const contentEl = { replaceChildren() {}, focus() {} };\n"
"const anchor = { href: '', hidden: true };\n"
"const openEl = anchor;\n"
"const hist = [];\n"
"globalThis.history = { state: null,"
" pushState(s, t) { hist.push(['push', s.modal === 'doc', s.source, s.path, t]); },"
" replaceState(s, t) { hist.push(['replace', s.modal === 'doc', s.source, s.path]); } };\n"
"globalThis.document = { addEventListener() {}, removeEventListener() {} };\n"
"globalThis.fetch = () => Promise.resolve({ ok: true, json: async () => ({}) });\n"
"function onModalKeydown() {}\n"
"function showLoading() {}\n"
"function setDesc() {}\n"
"function renderNotFound() {}\n"
"function wireCopyPath() {}\n"
"function renderDocument() {}\n"
'function contentUrl(s, p) { return ""; }\n'
+ url_fn
+ "\n"
+ fn
+ "\nopenDocumentModal('H omelab', 'a/b c.md', null, '/');\n"
+ "\nconst chatHref = anchor.href;\n"
+ "\nopenDocumentModal('S2', 'p2.md', null);\n"
+ "\nconsole.log(JSON.stringify({ chatHref, sourcesHref: anchor.href,"
+ " visible: !modalEl.hidden, hist }));\n"
)
out = json.loads(_run_node(script))
pre_phase = "/document.html?source=H%20omelab&path=a%2Fb%20c.md"
assert out["chatHref"] == pre_phase + "&back=%2F", (
"a chat open must set the Full-page anchor to the pre-phase string + exactly &back=%2F"
)
assert out["sourcesHref"] == "/document.html?source=S2&path=p2.md", (
"a Sources-style open (back omitted) must reset the anchor to the byte-"
"identical no-param string"
)
assert out["visible"] is True
# phase 144 stands: one state-only push, then the defensive
# re-open replaces IN PLACE (no second push).
assert out["hist"] == [
["push", True, "H omelab", "a/b c.md", ""],
["replace", True, "S2", "p2.md"],
]
+1 -1
View File
@@ -842,7 +842,7 @@ def test_answer_image_helper_builds_the_lazy_capturing_figure() -> None:
body = _function_body(_js(), "function appendAnswerImage(wrap, ref) {")
assert 'fig.className = "answer-image";' in body
assert 'fig.href = documentUrl(ref.source, ref.path, "/");' in body
assert "openDocumentModal(ref.source, ref.path, fig);" in body
assert "openDocumentModal(ref.source, ref.path, fig, \"/\")" in body
assert "e.preventDefault();" in body # no new tab (phase 26)
assert 'img.className = "answer-image-img";' in body
assert "img.src = ref.image_url;" in body # the public bytes route
+2 -2
View File
@@ -1390,7 +1390,7 @@ def test_chat_sources_figure_reads_image_url_sets_alt_and_falls_back_in_place()
assert 'chip.href = documentUrl(s.source, s.path, "/");' in body, (
"the fallback chip keeps the chip's /document.html href"
)
assert "openDocumentModal(s.source, s.path, chip);" in body, (
assert "openDocumentModal(s.source, s.path, chip, \"/\")" in body, (
"the fallback chip keeps the left-click → same-page modal"
)
assert "e.preventDefault();" in body # no new tab (phase 26)
@@ -1406,7 +1406,7 @@ def test_chat_sources_figure_reads_image_url_sets_alt_and_falls_back_in_place()
assert "innerHTML" not in code, "no innerHTML from document-derived data"
# The figure reuses the chip's navigation (phase 26 contract):
assert 'fig.href = documentUrl(s.source, s.path, "/");' in body
assert "openDocumentModal(s.source, s.path, fig);" in body
assert "openDocumentModal(s.source, s.path, fig, \"/\")" in body
def test_chat_sources_splits_the_chips_row_from_the_images_row() -> None:
+1 -1
View File
@@ -159,7 +159,7 @@ def test_append_related_exists_and_never_uses_source_chip() -> None:
"each related link keeps the chip's /document.html href — the "
"no-JS / context-menu escape hatch (back → the chat page)"
)
assert "openDocumentModal(s.source, s.path, link);" in body, (
assert "openDocumentModal(s.source, s.path, link, \"/\")" in body, (
"left-click opens the same-page document modal exactly like the "
"chips (phase 26 contract)"
)