From 966c4b054017d04e3ef7b31fc44df59519aa2599 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Sun, 4 Oct 2026 09:23:54 -0400 Subject: [PATCH] phase: 153_document_viewer_context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. --- .../153_document_viewer_context/00_phase.md | 43 + .../01_modal_back_context.md | 24 + .../02_copy_path.md | 25 + .../03_unit_pins.md | 23 + .../04_e2e_viewer_context.md | 25 + .../154_sync_status_freshness/00_phase.md | 44 + .../01_rag_sync_stamp.md | 23 + .../02_upload_line_stamp.md | 28 + .../154_sync_status_freshness/03_unit_pins.md | 22 + .../04_e2e_status_freshness.md | 22 + .../todo/155_folder_summary_voice/00_phase.md | 42 + .../01_prompt_nudge.md | 21 + .../02_regen_script.md | 28 + .../155_folder_summary_voice/03_unit_pins.md | 22 + .../04_e2e_folder_voice.md | 21 + .../todo/156_table_ux_polish/00_phase.md | 43 + .../156_table_ux_polish/01_sticky_header.md | 22 + .../156_table_ux_polish/02_ellipsis_hints.md | 24 + .../todo/156_table_ux_polish/03_unit_pins.md | 24 + .../04_e2e_table_polish.md | 23 + .../phases/todo/157_chat_chip_cap/00_phase.md | 41 + .../todo/157_chat_chip_cap/01_chip_cap.md | 22 + .../todo/157_chat_chip_cap/02_unit_pins.md | 23 + .../todo/157_chat_chip_cap/03_e2e_chip_cap.md | 23 + .../todo/158_thinking_timeline/00_phase.md | 427 ++++++++++ .../01_schema_segments.md | 69 ++ .../158_thinking_timeline/02_live_timeline.md | 195 +++++ .../03_restore_and_shared.md | 82 ++ .../158_thinking_timeline/04_mock_and_e2e.md | 115 +++ .../05_regression_update_and_commit.md | 85 ++ ...3_document_viewer_context__00_phase.a1.err | 0 ...53_document_viewer_context__00_phase.a1.md | 22 + ...ument_viewer_context__00_phase.a1.validate | 121 +++ ...ewer_context__01_modal_back_context.a1.err | 0 ...iewer_context__01_modal_back_context.a1.md | 20 + ...context__01_modal_back_context.a1.validate | 121 +++ ...cument_viewer_context__02_copy_path.a1.err | 0 ...ocument_viewer_context__02_copy_path.a1.md | 16 + ...t_viewer_context__02_copy_path.a1.validate | 121 +++ ...cument_viewer_context__03_unit_pins.a1.err | 0 ...ocument_viewer_context__03_unit_pins.a1.md | 10 + ...t_viewer_context__03_unit_pins.a1.validate | 121 +++ ...ewer_context__04_e2e_viewer_context.a1.err | 0 ...iewer_context__04_e2e_viewer_context.a1.md | 13 + ...context__04_e2e_viewer_context.a1.validate | 121 +++ .../screenshots/doc_modal_scrollbar_1280.png | Bin 42559 -> 43426 bytes README.md | 12 + frontend/assets/app.js | 21 +- frontend/assets/document-modal.js | 151 +++- frontend/assets/document.js | 179 ++++ frontend/assets/styles.css | 90 ++ frontend/document.html | 11 + tests/e2e/test_document_viewer.py | 5 +- tests/e2e/test_document_viewer_context.py | 486 +++++++++++ tests/unit/test_document_viewer.py | 797 +++++++++++++++++- tests/unit/test_frontend_doc_modal.py | 261 +++++- tests/unit/test_image_chips_and_answers.py | 2 +- tests/unit/test_image_documents.py | 4 +- tests/unit/test_source_chip_quality.py | 2 +- 59 files changed, 4304 insertions(+), 34 deletions(-) create mode 100644 .agents/phases/complete/153_document_viewer_context/00_phase.md create mode 100644 .agents/phases/complete/153_document_viewer_context/01_modal_back_context.md create mode 100644 .agents/phases/complete/153_document_viewer_context/02_copy_path.md create mode 100644 .agents/phases/complete/153_document_viewer_context/03_unit_pins.md create mode 100644 .agents/phases/complete/153_document_viewer_context/04_e2e_viewer_context.md create mode 100644 .agents/phases/todo/154_sync_status_freshness/00_phase.md create mode 100644 .agents/phases/todo/154_sync_status_freshness/01_rag_sync_stamp.md create mode 100644 .agents/phases/todo/154_sync_status_freshness/02_upload_line_stamp.md create mode 100644 .agents/phases/todo/154_sync_status_freshness/03_unit_pins.md create mode 100644 .agents/phases/todo/154_sync_status_freshness/04_e2e_status_freshness.md create mode 100644 .agents/phases/todo/155_folder_summary_voice/00_phase.md create mode 100644 .agents/phases/todo/155_folder_summary_voice/01_prompt_nudge.md create mode 100644 .agents/phases/todo/155_folder_summary_voice/02_regen_script.md create mode 100644 .agents/phases/todo/155_folder_summary_voice/03_unit_pins.md create mode 100644 .agents/phases/todo/155_folder_summary_voice/04_e2e_folder_voice.md create mode 100644 .agents/phases/todo/156_table_ux_polish/00_phase.md create mode 100644 .agents/phases/todo/156_table_ux_polish/01_sticky_header.md create mode 100644 .agents/phases/todo/156_table_ux_polish/02_ellipsis_hints.md create mode 100644 .agents/phases/todo/156_table_ux_polish/03_unit_pins.md create mode 100644 .agents/phases/todo/156_table_ux_polish/04_e2e_table_polish.md create mode 100644 .agents/phases/todo/157_chat_chip_cap/00_phase.md create mode 100644 .agents/phases/todo/157_chat_chip_cap/01_chip_cap.md create mode 100644 .agents/phases/todo/157_chat_chip_cap/02_unit_pins.md create mode 100644 .agents/phases/todo/157_chat_chip_cap/03_e2e_chip_cap.md create mode 100644 .agents/phases/todo/158_thinking_timeline/00_phase.md create mode 100644 .agents/phases/todo/158_thinking_timeline/01_schema_segments.md create mode 100644 .agents/phases/todo/158_thinking_timeline/02_live_timeline.md create mode 100644 .agents/phases/todo/158_thinking_timeline/03_restore_and_shared.md create mode 100644 .agents/phases/todo/158_thinking_timeline/04_mock_and_e2e.md create mode 100644 .agents/phases/todo/158_thinking_timeline/05_regression_update_and_commit.md create mode 100644 .agents/reports/153_document_viewer_context/153_document_viewer_context__00_phase.a1.err create mode 100644 .agents/reports/153_document_viewer_context/153_document_viewer_context__00_phase.a1.md create mode 100644 .agents/reports/153_document_viewer_context/153_document_viewer_context__00_phase.a1.validate create mode 100644 .agents/reports/153_document_viewer_context/153_document_viewer_context__01_modal_back_context.a1.err create mode 100644 .agents/reports/153_document_viewer_context/153_document_viewer_context__01_modal_back_context.a1.md create mode 100644 .agents/reports/153_document_viewer_context/153_document_viewer_context__01_modal_back_context.a1.validate create mode 100644 .agents/reports/153_document_viewer_context/153_document_viewer_context__02_copy_path.a1.err create mode 100644 .agents/reports/153_document_viewer_context/153_document_viewer_context__02_copy_path.a1.md create mode 100644 .agents/reports/153_document_viewer_context/153_document_viewer_context__02_copy_path.a1.validate create mode 100644 .agents/reports/153_document_viewer_context/153_document_viewer_context__03_unit_pins.a1.err create mode 100644 .agents/reports/153_document_viewer_context/153_document_viewer_context__03_unit_pins.a1.md create mode 100644 .agents/reports/153_document_viewer_context/153_document_viewer_context__03_unit_pins.a1.validate create mode 100644 .agents/reports/153_document_viewer_context/153_document_viewer_context__04_e2e_viewer_context.a1.err create mode 100644 .agents/reports/153_document_viewer_context/153_document_viewer_context__04_e2e_viewer_context.a1.md create mode 100644 .agents/reports/153_document_viewer_context/153_document_viewer_context__04_e2e_viewer_context.a1.validate create mode 100644 tests/e2e/test_document_viewer_context.py diff --git a/.agents/phases/complete/153_document_viewer_context/00_phase.md b/.agents/phases/complete/153_document_viewer_context/00_phase.md new file mode 100644 index 0000000..04b18f5 --- /dev/null +++ b/.agents/phases/complete/153_document_viewer_context/00_phase.md @@ -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`) diff --git a/.agents/phases/complete/153_document_viewer_context/01_modal_back_context.md b/.agents/phases/complete/153_document_viewer_context/01_modal_back_context.md new file mode 100644 index 0000000..fe45c45 --- /dev/null +++ b/.agents/phases/complete/153_document_viewer_context/01_modal_back_context.md @@ -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) diff --git a/.agents/phases/complete/153_document_viewer_context/02_copy_path.md b/.agents/phases/complete/153_document_viewer_context/02_copy_path.md new file mode 100644 index 0000000..55e0009 --- /dev/null +++ b/.agents/phases/complete/153_document_viewer_context/02_copy_path.md @@ -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 diff --git a/.agents/phases/complete/153_document_viewer_context/03_unit_pins.md b/.agents/phases/complete/153_document_viewer_context/03_unit_pins.md new file mode 100644 index 0000000..4492b46 --- /dev/null +++ b/.agents/phases/complete/153_document_viewer_context/03_unit_pins.md @@ -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 diff --git a/.agents/phases/complete/153_document_viewer_context/04_e2e_viewer_context.md b/.agents/phases/complete/153_document_viewer_context/04_e2e_viewer_context.md new file mode 100644 index 0000000..d5621cb --- /dev/null +++ b/.agents/phases/complete/153_document_viewer_context/04_e2e_viewer_context.md @@ -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 diff --git a/.agents/phases/todo/154_sync_status_freshness/00_phase.md b/.agents/phases/todo/154_sync_status_freshness/00_phase.md new file mode 100644 index 0000000..edbb1e0 --- /dev/null +++ b/.agents/phases/todo/154_sync_status_freshness/00_phase.md @@ -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 — 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 () — press Sync sources to import it.` / `Uploaded — 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`) diff --git a/.agents/phases/todo/154_sync_status_freshness/01_rag_sync_stamp.md b/.agents/phases/todo/154_sync_status_freshness/01_rag_sync_stamp.md new file mode 100644 index 0000000..3b30c4f --- /dev/null +++ b/.agents/phases/todo/154_sync_status_freshness/01_rag_sync_stamp.md @@ -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 diff --git a/.agents/phases/todo/154_sync_status_freshness/02_upload_line_stamp.md b/.agents/phases/todo/154_sync_status_freshness/02_upload_line_stamp.md new file mode 100644 index 0000000..c1ce509 --- /dev/null +++ b/.agents/phases/todo/154_sync_status_freshness/02_upload_line_stamp.md @@ -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 diff --git a/.agents/phases/todo/154_sync_status_freshness/03_unit_pins.md b/.agents/phases/todo/154_sync_status_freshness/03_unit_pins.md new file mode 100644 index 0000000..5f41e91 --- /dev/null +++ b/.agents/phases/todo/154_sync_status_freshness/03_unit_pins.md @@ -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 diff --git a/.agents/phases/todo/154_sync_status_freshness/04_e2e_status_freshness.md b/.agents/phases/todo/154_sync_status_freshness/04_e2e_status_freshness.md new file mode 100644 index 0000000..3d6697d --- /dev/null +++ b/.agents/phases/todo/154_sync_status_freshness/04_e2e_status_freshness.md @@ -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 () — 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 — 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 diff --git a/.agents/phases/todo/155_folder_summary_voice/00_phase.md b/.agents/phases/todo/155_folder_summary_voice/00_phase.md new file mode 100644 index 0000000..aa1a8fb --- /dev/null +++ b/.agents/phases/todo/155_folder_summary_voice/00_phase.md @@ -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 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 , covering …" — 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`) diff --git a/.agents/phases/todo/155_folder_summary_voice/01_prompt_nudge.md b/.agents/phases/todo/155_folder_summary_voice/01_prompt_nudge.md new file mode 100644 index 0000000..2918433 --- /dev/null +++ b/.agents/phases/todo/155_folder_summary_voice/01_prompt_nudge.md @@ -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 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 …" — 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 diff --git a/.agents/phases/todo/155_folder_summary_voice/02_regen_script.md b/.agents/phases/todo/155_folder_summary_voice/02_regen_script.md new file mode 100644 index 0000000..40c9502 --- /dev/null +++ b/.agents/phases/todo/155_folder_summary_voice/02_regen_script.md @@ -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= …` 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 diff --git a/.agents/phases/todo/155_folder_summary_voice/03_unit_pins.md b/.agents/phases/todo/155_folder_summary_voice/03_unit_pins.md new file mode 100644 index 0000000..134d5d4 --- /dev/null +++ b/.agents/phases/todo/155_folder_summary_voice/03_unit_pins.md @@ -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 diff --git a/.agents/phases/todo/155_folder_summary_voice/04_e2e_folder_voice.md b/.agents/phases/todo/155_folder_summary_voice/04_e2e_folder_voice.md new file mode 100644 index 0000000..195c292 --- /dev/null +++ b/.agents/phases/todo/155_folder_summary_voice/04_e2e_folder_voice.md @@ -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 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) diff --git a/.agents/phases/todo/156_table_ux_polish/00_phase.md b/.agents/phases/todo/156_table_ux_polish/00_phase.md new file mode 100644 index 0000000..152cee8 --- /dev/null +++ b/.agents/phases/todo/156_table_ux_polish/00_phase.md @@ -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`) diff --git a/.agents/phases/todo/156_table_ux_polish/01_sticky_header.md b/.agents/phases/todo/156_table_ux_polish/01_sticky_header.md new file mode 100644 index 0000000..eeb2b38 --- /dev/null +++ b/.agents/phases/todo/156_table_ux_polish/01_sticky_header.md @@ -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 diff --git a/.agents/phases/todo/156_table_ux_polish/02_ellipsis_hints.md b/.agents/phases/todo/156_table_ux_polish/02_ellipsis_hints.md new file mode 100644 index 0000000..4c0e8c2 --- /dev/null +++ b/.agents/phases/todo/156_table_ux_polish/02_ellipsis_hints.md @@ -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 ` / `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 diff --git a/.agents/phases/todo/156_table_ux_polish/03_unit_pins.md b/.agents/phases/todo/156_table_ux_polish/03_unit_pins.md new file mode 100644 index 0000000..73af0bb --- /dev/null +++ b/.agents/phases/todo/156_table_ux_polish/03_unit_pins.md @@ -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 diff --git a/.agents/phases/todo/156_table_ux_polish/04_e2e_table_polish.md b/.agents/phases/todo/156_table_ux_polish/04_e2e_table_polish.md new file mode 100644 index 0000000..466b937 --- /dev/null +++ b/.agents/phases/todo/156_table_ux_polish/04_e2e_table_polish.md @@ -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) diff --git a/.agents/phases/todo/157_chat_chip_cap/00_phase.md b/.agents/phases/todo/157_chat_chip_cap/00_phase.md new file mode 100644 index 0000000..8cb65ca --- /dev/null +++ b/.agents/phases/todo/157_chat_chip_cap/00_phase.md @@ -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`) diff --git a/.agents/phases/todo/157_chat_chip_cap/01_chip_cap.md b/.agents/phases/todo/157_chat_chip_cap/01_chip_cap.md new file mode 100644 index 0000000..282f2ae --- /dev/null +++ b/.agents/phases/todo/157_chat_chip_cap/01_chip_cap.md @@ -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 diff --git a/.agents/phases/todo/157_chat_chip_cap/02_unit_pins.md b/.agents/phases/todo/157_chat_chip_cap/02_unit_pins.md new file mode 100644 index 0000000..5cf8f86 --- /dev/null +++ b/.agents/phases/todo/157_chat_chip_cap/02_unit_pins.md @@ -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 diff --git a/.agents/phases/todo/157_chat_chip_cap/03_e2e_chip_cap.md b/.agents/phases/todo/157_chat_chip_cap/03_e2e_chip_cap.md new file mode 100644 index 0000000..7a76ae7 --- /dev/null +++ b/.agents/phases/todo/157_chat_chip_cap/03_e2e_chip_cap.md @@ -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) diff --git a/.agents/phases/todo/158_thinking_timeline/00_phase.md b/.agents/phases/todo/158_thinking_timeline/00_phase.md new file mode 100644 index 0000000..5fd66fc --- /dev/null +++ b/.agents/phases/todo/158_thinking_timeline/00_phase.md @@ -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. diff --git a/.agents/phases/todo/158_thinking_timeline/01_schema_segments.md b/.agents/phases/todo/158_thinking_timeline/01_schema_segments.md new file mode 100644 index 0000000..4923825 --- /dev/null +++ b/.agents/phases/todo/158_thinking_timeline/01_schema_segments.md @@ -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) diff --git a/.agents/phases/todo/158_thinking_timeline/02_live_timeline.md b/.agents/phases/todo/158_thinking_timeline/02_live_timeline.md new file mode 100644 index 0000000..3c0cd2f --- /dev/null +++ b/.agents/phases/todo/158_thinking_timeline/02_live_timeline.md @@ -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 diff --git a/.agents/phases/todo/158_thinking_timeline/03_restore_and_shared.md b/.agents/phases/todo/158_thinking_timeline/03_restore_and_shared.md new file mode 100644 index 0000000..85ddcf3 --- /dev/null +++ b/.agents/phases/todo/158_thinking_timeline/03_restore_and_shared.md @@ -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 diff --git a/.agents/phases/todo/158_thinking_timeline/04_mock_and_e2e.md b/.agents/phases/todo/158_thinking_timeline/04_mock_and_e2e.md new file mode 100644 index 0000000..edc9df5 --- /dev/null +++ b/.agents/phases/todo/158_thinking_timeline/04_mock_and_e2e.md @@ -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 diff --git a/.agents/phases/todo/158_thinking_timeline/05_regression_update_and_commit.md b/.agents/phases/todo/158_thinking_timeline/05_regression_update_and_commit.md new file mode 100644 index 0000000..df8ea6b --- /dev/null +++ b/.agents/phases/todo/158_thinking_timeline/05_regression_update_and_commit.md @@ -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/` diff --git a/.agents/reports/153_document_viewer_context/153_document_viewer_context__00_phase.a1.err b/.agents/reports/153_document_viewer_context/153_document_viewer_context__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/153_document_viewer_context/153_document_viewer_context__00_phase.a1.md b/.agents/reports/153_document_viewer_context/153_document_viewer_context__00_phase.a1.md new file mode 100644 index 0000000..7bab897 --- /dev/null +++ b/.agents/reports/153_document_viewer_context/153_document_viewer_context__00_phase.a1.md @@ -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`. diff --git a/.agents/reports/153_document_viewer_context/153_document_viewer_context__00_phase.a1.validate b/.agents/reports/153_document_viewer_context/153_document_viewer_context__00_phase.a1.validate new file mode 100644 index 0000000..32ad713 --- /dev/null +++ b/.agents/reports/153_document_viewer_context/153_document_viewer_context__00_phase.a1.validate @@ -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 diff --git a/.agents/reports/153_document_viewer_context/153_document_viewer_context__01_modal_back_context.a1.err b/.agents/reports/153_document_viewer_context/153_document_viewer_context__01_modal_back_context.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/153_document_viewer_context/153_document_viewer_context__01_modal_back_context.a1.md b/.agents/reports/153_document_viewer_context/153_document_viewer_context__01_modal_back_context.a1.md new file mode 100644 index 0000000..b9ef198 --- /dev/null +++ b/.agents/reports/153_document_viewer_context/153_document_viewer_context__01_modal_back_context.a1.md @@ -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` diff --git a/.agents/reports/153_document_viewer_context/153_document_viewer_context__01_modal_back_context.a1.validate b/.agents/reports/153_document_viewer_context/153_document_viewer_context__01_modal_back_context.a1.validate new file mode 100644 index 0000000..6198b95 --- /dev/null +++ b/.agents/reports/153_document_viewer_context/153_document_viewer_context__01_modal_back_context.a1.validate @@ -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 diff --git a/.agents/reports/153_document_viewer_context/153_document_viewer_context__02_copy_path.a1.err b/.agents/reports/153_document_viewer_context/153_document_viewer_context__02_copy_path.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/153_document_viewer_context/153_document_viewer_context__02_copy_path.a1.md b/.agents/reports/153_document_viewer_context/153_document_viewer_context__02_copy_path.a1.md new file mode 100644 index 0000000..e5aa4aa --- /dev/null +++ b/.agents/reports/153_document_viewer_context/153_document_viewer_context__02_copy_path.a1.md @@ -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). diff --git a/.agents/reports/153_document_viewer_context/153_document_viewer_context__02_copy_path.a1.validate b/.agents/reports/153_document_viewer_context/153_document_viewer_context__02_copy_path.a1.validate new file mode 100644 index 0000000..0e6f596 --- /dev/null +++ b/.agents/reports/153_document_viewer_context/153_document_viewer_context__02_copy_path.a1.validate @@ -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 diff --git a/.agents/reports/153_document_viewer_context/153_document_viewer_context__03_unit_pins.a1.err b/.agents/reports/153_document_viewer_context/153_document_viewer_context__03_unit_pins.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/153_document_viewer_context/153_document_viewer_context__03_unit_pins.a1.md b/.agents/reports/153_document_viewer_context/153_document_viewer_context__03_unit_pins.a1.md new file mode 100644 index 0000000..6a7e67e --- /dev/null +++ b/.agents/reports/153_document_viewer_context/153_document_viewer_context__03_unit_pins.a1.md @@ -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` diff --git a/.agents/reports/153_document_viewer_context/153_document_viewer_context__03_unit_pins.a1.validate b/.agents/reports/153_document_viewer_context/153_document_viewer_context__03_unit_pins.a1.validate new file mode 100644 index 0000000..32ad713 --- /dev/null +++ b/.agents/reports/153_document_viewer_context/153_document_viewer_context__03_unit_pins.a1.validate @@ -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 diff --git a/.agents/reports/153_document_viewer_context/153_document_viewer_context__04_e2e_viewer_context.a1.err b/.agents/reports/153_document_viewer_context/153_document_viewer_context__04_e2e_viewer_context.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/153_document_viewer_context/153_document_viewer_context__04_e2e_viewer_context.a1.md b/.agents/reports/153_document_viewer_context/153_document_viewer_context__04_e2e_viewer_context.a1.md new file mode 100644 index 0000000..0e8bc8e --- /dev/null +++ b/.agents/reports/153_document_viewer_context/153_document_viewer_context__04_e2e_viewer_context.a1.md @@ -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` diff --git a/.agents/reports/153_document_viewer_context/153_document_viewer_context__04_e2e_viewer_context.a1.validate b/.agents/reports/153_document_viewer_context/153_document_viewer_context__04_e2e_viewer_context.a1.validate new file mode 100644 index 0000000..32ad713 --- /dev/null +++ b/.agents/reports/153_document_viewer_context/153_document_viewer_context__04_e2e_viewer_context.a1.validate @@ -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 diff --git a/.agents/screenshots/doc_modal_scrollbar_1280.png b/.agents/screenshots/doc_modal_scrollbar_1280.png index 1c506d87bedda02f7847c34f62b7454d43f94767..a719bc1b919c1cf9fb35e7ea8223f3e29817b0f7 100644 GIT binary patch literal 43426 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 literal 42559 zcmXtfbwE??`}a^01%XE?X%!Wvk(ROm5oyK<DUoh)bf^eCqJT(CjE;>lq#FbQ>D+*g zlo)J;#E6Z-dw9O@@2~A_=UnGL=Un%FU7t9g^mH^B>AC1ZAP}SG^JfMi5H0Za{EbWJ zfRA0WNht{Q4@mRbV<Z2J^%=Sh7W8b_wkGa{__+`NvXu+VmfyG~Wb#j^Wx3xoBTF`6 z_6uxmjk%LSTg~sHCuxb*)@p$cl)C}2{#36??TQ}viu8fw7FhG_*MYkUJ^eiagQxvp zTDA4#^{o~>CxH`!K-oY1mH6^nn$$z$Lut-_h$+3PUzJ6Zy2o&~<#>(6lutr|&Yx`z ze;F498J?UC7$luT?i@`#FR{k)-#<%)t2%}ZZTVJbTaS{T%j9}rkvRL_#&)Ug>=Oj} zD|jS-SM|R@6){O*Wj@^p%sR4XkuUo<i3Xo2#Z7E&U%Yin@=u_2Rex+>Ab8dZ@xYm) zggSE2Kb>b}K_F9s(ZY1o-UgdDqEh=2r;VTT{jscUcXs75_d#s~JI%AnvyZ%PlgVGI z;8*ChKA;g#z-%|aNr0s9FgmJ#^O)pGm0r}9)wrPu3mV|I`MhAMY5eXoFudEjuRVgo zTHr0l4g$9Ah^J^RxRB8OOXnZuRj%xOO!pydKSN+{*50jsd(nDS9Ml$Z%;!ND7TWhk z!>Z_Sv^9E^5bfL&K$ETSKQNgnl`~??mzw9t;=O$E`~l}A<ey8oLoWdLY5s|tWmxTT z?l~0d>KMv@FB(Y=V9WWZ`{MaWg-1=Z<!PZYJl8&X@aZlg<KNqs2!IlRCn7f9l`^4# zY8xvaIZzFpF;;1fZex364f=g0E-f^?K4^z_S-$ykN76Ni`37O&QtEom-hc2t3C{a8 z>XBF!A)bBb9s?~S=uty}a#jZHcIg$KG$yw?oj;&{yRY6nY{8)Cnl4t^1>*no#;#Px zsG=V5m~oaq0D+wTNS-&42qh2vVc;)#d2`M|J><#z7<x~sF5fGlHSjzM+9NDoq4{IT zii_S`M+Eeo12%#Ci}jGA@3A53Imm^?-7W()zP0q1x)ytS9`szGwR`N&SBtfY+=87T z?~)rdAnm&r-BekWSi@|EOB`L9=w`GhH6k(n;(ZR#ci<w<SsAaq6irGV0CMf+BF{m3 zCiUoRfKvGa)29Rx0UEF6fF`o!gD-UqpsDO(qVSIWR)A!cYeYQ50~!!lzNS#{)bPRt z_kl#(Z&u26>y(Mm=Ex%3>};Rx@)%X!#Z9{$Yc2>KaIOkH{BP;SQa)Bps)$3!xp#q| zGnf^GrF3iy4Qex4AKI_rMTTXncW4tsLn0kCp%Eujv#_~Q4ih8r>{oZX93X%x1FH1~ zk%%yBe~oIrZR7?55$-5bmIe!Y9k16!xA9Flu$=oFCS5msU(aN9H|ZjR!zc+7A<*3| zeBevZx_W|XdAC`5rO0zwsUEoWh}TxThqB||*WS@&^C=DF?>LFSiTvg{X~wphwV?%y z{mYfb^!`}3rHSCV3hNz3|2%Eri2fM{Nv`>Oq)2AK42WCumA*0nryY{NF3n!3>T{c{ zuY@XIrw1E>MNO;WP0^Ym?}V3@m)STt@U-VFQMC`Rm%>Y5Sr}ZSk9g=u$PW{HRX6N- ztB0T3nA_m|7@P*hZEY_F1aF)RkU43(#d@5V`%Lauc+tuCZqB5L4_s!V3}8q^J58M6 z9MNT&;dWZvXRcR;Oc&0HnpGVh2o#r<4QrGKLhtbkK6CRmt9C5`>r+lDc6RLgk~vAa zqD>aKnmYvr2xH`f3iEVXw`h8X6qJd+nVIw4loPT#SY_^*mZ3`1ak=WA80LI$Utdf{ z0t`0SV$p~_KsZ!b|NP(|hK7TMT{7h%W<KLNAwh14>Mg4u-gc&@rU`PtIsZrwol81i zWT2<ti(vb!!R1h$yv)YtEDANJ)Nze=Y9(I?e=LyjAn_wx2>OKkf^~{;^efVj-wyuC z#t@N`vU0g7+0IV#*3P%*g6i>gx9b`*Ra-MvQ3Y}mDELd|RIZ`1+c<q928pY2skJ@{ zmN8M!bHUZPeNGldOW(tYnYRS5eY<m?T0cY-?Hv;D@1xj`<ifAW-2#2jc*x=`tz^$( zn0dK%j{lyJkduk30d?fV@o~gwc1}jURZ>CV4*p#?0v7FAE^TCCrr)d+`eXMB7;_Jv z_2ae?8fzi|5+)C#L@GlOs|ZO9_$NFVRV!bQvTf})pC=_n5vHu+V`F6&ibUtHgn9CL z8mfuKLyl3d;?mM_#*mXxn7HGE{;V>6E6aoj`6I_iO{Xu8%b9o|8bQGKS-1wGueFfY z5a;tG@|1IXc|J4O3$Pif$$3|IU`$eF=-Kn7*zFkYIafH6D{@cPwgZuvQHKXy`X6qW z-pCgJNm~+UTo=qPO*84Zz);*wK~&^-?b)Axk7=p7)@e(!XjoyZ-lNT+k)ORd-W=rT z@=kjon#(vQ$GPra2IjqQ^II@Wk}f(0tl`{q4eSY7b1TH!9=dG54ypHbMZY+zcl*rF zkdE^ce3w`phc1M=ge=0({}>gD4N!_WI&%Km#8~$rr{yr_BBHwo1}?V{*2`x1cLKjz zdTR<*v$2e)n%kMRnqoyS8`E{zC|U@Mny=LwW@jg<&PysvHAoc*GqDCyNlb0d2=rg) zOmWKwBP(^Y-G=VWWvlx#Ymw1PNb~AZzp7p<^$ruOJutdnLfeAkWU8iSr>6Fy`<ZOc zqwEGwoyF*N+LhwwYx8Ejig4yi`Oe43_HJrbV+D_U+CQ8N!T4s*rRSwYYeP>e1M4HF zUYLX{#>anfT(xL$O~V)@Twyoy_{kYgJh*e6b9ICx;q&LV3-iD2S*6=G`KA#gWb3y+ zn)V2Ni0+Xd<x9&=Y?T}0W>@ED4(_!|@6PC?PkFXovYWsEb-I@if#6F-lEi~tE<Hlz z<~J$LZFx-Os#y=DT}OV7He+R7qB|G3@-3SFjw}A((yldJt)u=wmmCj~6%B!F1av_x z3a`}K`m`^Z?2(_qamCmOjJzib)%l@wyaamiEJQ@6hK-F)c;m}eCUZl`trP-7#Gav% z$z*Qbq|4nZFalyu;_T=bBz!&;urt#w2(5Nb;>-SdSc5dD^tYI$=w$BAsc9vTqXe=X z@;#*!lVwIN)x;$xM~PO~#88(mZa5TQq7{Dn_|8&rhxs)oR9Ttp@~Izup#n}%wNVTU z6}Eb0b<<|oC4YEP%Ao%6Lvqb5W6;tP%zi=G)pzE}PwRTy{jY)kz3-iIXd!~6*^n_a zs<`<10m9fL%s2BPbkuMlUDc|VglNyrrDb5Ra-OQlE|<6_04W`gx}N5knNp;y?Npwh z|BGOWn4c~L3panYY{y<NmE%iuHZjVd<Fdc0g967(_!Xp8(|+eiKsA7ctf~{3`_q=| zu^TuSocU|}{8-+Ht3R>NNXa4gYyMCtr?j*JfiWIz)kvfp^gD~vs6?wexcNu)kQrM8 zGR11VeC^8pCMTaFpB9&nS<V5)>VEQS3hI@UXt|0Wv|0d`S$`QRc3+>)%)>ZtqG2@d zr2o4TkH^zd6<h$adM~PB?0Xc74QygkOtRH|IxLa4sm~`YiJz0{jlMKt$xP;{{un4g z-(_YN)K_c#{c1>gFX$*BAW#GI<-k9^788kC%I*AoY9X4VSD$yMwD~oH+XvJcOo?3F zn3%c|)IahvB-l8-RPMECIZ0L~cDT3H&GF9a({YW`=KDjz6L!nXgHO-X58w}us92@F z@~+hAZ!h|sLh6m4%|Vxx)m1q{HUq<j8nB}ID$kT>$$^C7mS(o}Y64J!-teyEbx$&v zPvj@?m9jrdH++Nqp8g(vQT?JL#!QR<{mg?^bae<^bTkvLPZ{9ET1;4)pIX#8>hFLh zh1&idU<t!LKe=X!;b?`L<6PRjMoMB`kOdwbGs7q4o<BeA1CN37bgtS5KUmF|&7Kd` zA)1XVE7=tnGlY1$7fLG!9}>(>Y`jm7k7SrM*04x(Cr!<kZ&~%?B_pO4{$4xU@coY8 z+155W`@$RVu@;$(hPCc9dur}6A4#ht9SyrfttW7ei7{uZ48eOIhDPH#VBwC8j6#UM zB^YmQx#uYYt#5$OY#)uQ&^^709InRIP=k7|DgG%#H&ln%iFaC46OE0rt=eZ(OK^^- zK*w`F!|Xf>1|K;2$R!6YC0V#b`F95z3L3sRKY`-rYBt&<7|>j+KaW;e(+#9LBM6Fo zTP|(Tt4PBq@~5-kPhYK)*!)AZ5HaW+f^k<XO<n)1cB}?%(J$P)f|zGkm2)LYg~l^! z4d{HtTp<Ri5KvE2NTLJZQu51?Nn1Wvhavybgw>K@`=?0p$1Y2M=29Fd2FWi*JwD$O z%-j>TXpr8C$>S-YiQn;VR&*0uj!6q8s_A!|*^h7(ypC(3vGrA6R5l<!<~41e`^_cQ z>Z81J*X_+6sI^&*qYW+Fo#b;;)#Uz?O3d}B271hj5k4~C!X%s*b2lfR$t{NbYeOZH zEE<@6R{`E<AM^UU{mMuqL#jnYL3IPYTYueaQv8?0^jfMvlD%X4@!M9+J4_roN)l2U zPkT-J_r}0sQ450I9ELh^I_)JRZP>4qt`IuSlzYBTtr<+Cg{oCvHG3i3$`!7_(UYS} zT&j7T^4=g%p|}Y_#QRUiX636U8)2nh|0Ed4&i-rTD^guji-20!=c42qTyS$%)f!5; zS`QDl<nem6<<}G#>R)atq0@s{CBBWo!2IOyhpG1>)|8BCCp#~-U9FD#*l>Z~{6e*z z|MC0w0Pv~X!GfcoAQ=0U)h<Q8xAil-*tl+=F;e<T>B!!4U&;^#&+l@h`;QE>tRCe$ z=S#O4vpcb>r^jQnb*Cp351B_~?CRtX+kVLS*YqwJySQ0?3MRB5Y9L*1=(!GlFq1^U zv`pmMY1u(H?Dy+3EwK{20Z~l6p{LY?KwX&d;E=d1<<}s+n_}hFS5@8xLbc>x)1r_d zW%W-J51-d*dwI2q91mJF7`Z*8B#YwMIf#QteJSQ)Sq@!05nNwIM9~irlZ{wO=dXu7 zG;d;|zl=VdH2Y4n#q-u0#l&HSsSs^g(!Jz#DecmzlAn9&<M9RzC87C&W{gS;dd=s{ zEd!mP`Qf9Z2Udj^ZkAH02)X$K|GoZ#5!2zvmA>ofiD-p|dL`CZ1|*OEF#*M8w!25U zEf+JG)r3V2oTPP^3~gug9%hp-tv^8Rq&^H2aw&c3Ze0G_GT+kd>U}T7jnX)C*vz*A zEu@zFM_zH86v`gswKb;iQ8&2k3+&W(71-5bk4@D&y>}5B%Ap}cgyvsY+P3*h-Yv;6 zIGSmhIdyh+Vp9DD3~zYY#xe-nLv^YG<QXa-OQBg)=^ji{tTVa41s)mS`8y|2HZYm( z_J~p!S^&)z&2cJPu9a53gg8Ob8hV1Q5N#s*lW`-8232EB)zVn%zqJ9lYC5P{>Eis} zF_wm8jtF-cH~TSl!!o?ok5{ubDM_o}l|R!0MgBi7Ko-&RTD(<7kHlBKh`^WV8cUiK zXoX-fjhKCp2u+teE+&t43j`tYhA{!gnQ@P{EVRrWFG4qv!p2Cr>563EN@qlbjZ~PK zx&P9%x0>3ZhEsxZpYiLmp6Z9bdkQUB*V8g|G(BQq98;iD6L|6oAO6ffaaBvNgFff0 zN@-%t^>&PjkYRS_w6CQr-V{~(A^&6|7n@fW<6irh?jif()0K`Y2<rKEuqf88H2&tu zBkO+4@7>dKrONm@i}vG7$r@4I)qN%JSAT3aaIO3(LPX@@J^t0`c+%Iuq8Wq2Zem6{ zixc*r%Iux?r#m!bqL=#^`c-we6htQ^qn-%#ab6Ltjtb3F&iSq6Q0evMOVanK>!zGC zbH{MKqOpf+X`}#Jo}ycNvSPpcTp+JX1<g#2YkDOeA^3#zzj@*oTO}j%NH)a$%kgj& zXYLmnjcQU7YBoGHsmt`k?6|=44xx7$eOl9YWNDSYdK>@y81MP~;6gv9aFS@nSt@kd zxOxXqrEra;7$_QZHD~Kv3en9Raml)RAeVmkKZWUndBF;$qsidb>YyKisb(ilk19Hy zm1kq=jxdHv$!0T`8x3umKU!#wKup?r`iiB`$64ywlvKqFs>;1n&V2mC%42~0G+$nl zpR;@-vOe@}p60pohq8lIt*g>2D|RF~v+peRB;RZ6AyD_{SiW1k9eL&vvvs+7fxPh= z-!&{`H#Ca`G>0-0-9;m1ylNu?HUooxR?P@4twQ2MipmCXmJo6hSXM+1HjKUU&FV*t zh-LWk7Vb+Kz1ygoeZ=(1(`H4Nl!@5Vq{34(0p121?P6IT;i*7M;AQAr0iWBIBHx^z zqMr~L2^M~?b1EzS!7k24A+588<l%)?n+NedOZK*oCU^@3e00+~ta|Ds^JanQuV6}G z&F?FEKHuu&tFTdY9j{|LFm+>NU6OhWgIZ6JZ1;X29G<{MoKQR2GTK^*JBJ=1%%vXh zFnI$8rYIScTz;$AEWR`1=+SPjSLqAJdqxnKrX249eWX9D!v`_N^SM$NPa!nEN3`*F zpG>1fWa(;85WCK}-w~AxXI$EEQ<b=)VSd{{56yPXJh254+MtcbEB^)D+^LfxPi=iq zus&nF-YM2mScfAmXnYk~yIKC03!Uw=B%h*7y+h&@4hM5xT#L7w#=t!OJGSf+D$U<Q zkrulm{G5D?t{(o7$I`~K<_A(0l_aj`Tt`fFJloG3Y_}H(!;Cw4?Cq?JEgc<1tCkgs z-f;ZL0f8Kw>bIl+JN42Pk3Fuk_-W#wJr|!K+R1v_dlw-Aw>T&=XECym3~NeaElChf zK~|<l*$q9KSg&I_miDtjFI}PhY1@zRRJfe1)p<OBUIq3ad8cr5{!&}c>KbY~<Gn>7 z@4P>MK-Lh%`7GMM19;NKCd<|#JjuV5UzeTd$#tfvrz0?eUa`>iE0Nz<#%ro4;x=8| z?fRsIgUMf<GmaH_ltq(qb&?>#>wo_SC<tE%!ssQ;E%q>9NGweJBLqhXBM=*U4sYlb zJqbl>mRTp}oM+GgEZ2MOuBEDWovU%%h%iQHX0yMojG-;#;OzE4&l+01-`i(MEf-=H zLc)8oh~E##`~#rX&RNlUv;hIV{&$j+(ijLmrLaP?ny{2%VhqH`*XE)x1ay~r_F#L2 z2m5v@!39Z4;Gg!sKZnarKcRKo@GfAz>}ANf^e8fvSmc#F!2Uvh5$nb|sU{Vw*$N!? z=vxz?E-v?$T_u~H-GCVNq0>T2y2oP=!I&uFB0K6E$0_Gaa)&hazeL&2I6e^Q>uvB| zFz%J5*>|bt)n4mzcvY3Q%>p4h8h#NLF4RmEZ0<8w-7GoZn0xLe`HU0&mj6-j`laFr zX|~Od&6##~cEHszetDE;qPk{Qb5Sgudnxq)UxH@zxa{sOBsqm!!5JJ7@cF}sM<L=T z-$qDdZFVm5a?gr*nEywm-q5s+E0P!Y0INmBkFi>rFX1N^Q*r7hswB&rw>?LoPCJjY z=l-lpOyrfmv3*_JPDO=mM1*=YmjiX&W9n=EE2g%@FkqK=w@YeF4fGejiB-)O&X&%4 z?}Uvy5{vlylJ{wU+bD3qBkumNBdRr@?fPXWKq(Z4s!*`nyax`fq=5~kPrq4Iw^Y3^ zYCdBSr=XDW*<0#AauW1ziEVi~$=H}n%+SCz?M+Vg_Jr<EUoz_hPu6Zw`t#NwZ_OPt zIV^*>`@~zDmNDir>t7>2UjUfo=_BPVPx)8Ng*zxU4^xk6D@abYCPw#LU-G$IaXlW- zjMa3IpU3IsMvwL>2U}s?nSKQI0MjX1oU84dAG?E!TZb$<!!f${gG!rqi_WUneWEp! zj(IU}ZRgwG9~~XBrK$2~Tk0IiuAs{5SH4H_1()CaEbSSmT!Zt)p;}MKqzNlI;Ku!v z#DB>s;z^|NS`2XmB3Qa9izaK@PZ{LRC%nite(79MsqgUaHL81Y`&%gEFG-jE^#}$I zLf02gDWQr**F#3r{4rg{jnu0G<oyj7AYR+eAv=xJhu|~~+}+TZhDg|(WKp~OT`(R7 zt@zxA1^fKDk0&QY)Z?J_5d;L~(Q?$~_zElS(zN<b?K4-!b8^CLy6bBoj<vQ5N{*Gu zp;*Y57$Yb4s%kZQd(mZjkPj0y@#0cf$u`E8TIGiGovN@aIf3)_8SCE1y!UyjLp#r^ zA@yCJy1z9Swn{^{w?0*=l_C~)yxko&R;0T3x5GGKY-gZ$jvU-l1Pdfo^oemp_ot|2 z63(-OF-62u$!DdX+r2KQD@*Fl@2^jp6y79g%TcKZbE?OGI~q5ec9S4h7C{@;93mzX z*0nQV0;imcG7G1D;@nGL{dnEY>sQ)sum%A>r|r`{W}AZ!3bayA4j1DfU$~3Z;JXC~ zIGjmwZ}=fcNpbNi8o+HX$m+L42vkepY(3tLxslK-%*><mO0G(Ya)qzS<@CfMhRcph zn19MN66YR#w49>B8}KF_gLLT6aH2ZHZaxjAY{(J!-D>AltlK~{sexO~6INl(S2HE~ zu3W4ojunB47<>$8p6>AC_If#W-#FZ4OAZh=hCiOO^iYFBzcIJWPy5Kt`rA@qK0qw^ zWtK1YIv%S3sJ<zz3eB%8b{ze9IZpX#$$YEHa1j!IkxA+J>r)+m8F*8ZQjddHlPf9Z z#l^uNpN2QfKysNq34+X6A6*JeS-aQOA%^Fulfz1$f64*pwpEYMYN3)%PuH<X+~ba@ z1HugDYdCWaUGAX5!8D7m(nv#dn&wd9p39q;544~=J%TW=m9+Pd+Q<$ZW9;UxUhDr} z1=1D~&s0!QFrky-PtDhCQ1<^j-^~|d<f1Ds`F2u*NRx0^TS!<OCgGJEf0zEWac?VZ zf64(>I~z#G19WTY0q&9qI|y`x<rd^X^Ui$9?k<+w!=yGv#KKuROsHz*h^n5E^p!xq ze_s?#R-P`clb0$_3gr*^^J!_!BeO3u@OLI?`b%%C5E{F7u*fO3GLT+$%?<z**c!JW z(&6^ggSokYx5U7^Ni1Q93sK7ZlX(ERZtxk?mYV@2DyyIknVHSe`rRMhd@joV0KVoA zIb7&c4l+ac^_TdR7%?eRzetrnD#ory@dszumKHeAwNO(}DXoUO3Z2hcl#ggSA~%|L z2Zg#U-)?u(F>(8D-$^;_55J>wdc5lZ3sA4+51xO&Q8QC>P?$~_<Zq#DRt~97#I&BC z;EGcaWevT7trh+~*Yd8;i2wxw3rHe*LLs)whimvOpINEk$?}^)TOl)ya>svLPuF0A ze15N5dHr~#eAlm479~!u#tWF&x>IMXoG6TZuZ&(DPjOf&?XBja?PrOtePW6v;zYF; z2U==8ENEw~2w5=wHbI+7@o=7L19y5fVP!S&9s2t*T?|ZqJ4L}~4-pb|Wvalc)fj0; zmGRr`h*N>nUDq5|eBs$SV>A(GvofnE;lH#HMQq(_I&InHti!p#XpK|aPbCd4-Lb5> z=;d-%UgMnS?)T&1QB6TpIX+;P3wOng#W2cF&i52$35kfT&o)!CABS6p9@*g+TaW*I zzAB)8A^c~L+{|8Ay2lD0PaFi+!8!8Adph2w0}YjKGy=l!(=Qp8PC1$}EWF3Um6qsV zqN6|>SK4j2^JqG0kdZn%!o$J{Yej6|{AQpNF|ycIU|Z+!5`99P>h{OotW>F7(TOko zr~Q>f?5wPN1T?V*=K-O4<>&0^Hocrej7;`y|9E+^E2i??-`cqlxf~l7Xkuv6F$!3w zrz-8MoyH0m&XP3yGFo8105VZMF&hR8X`!A_V&t%nDVe@z&CX~;`?gI?H3F_k&~O_? zOjw3rM4Hs^kmTl!bX}L6ib_z2YYwmyLEpAe%G6L^H;??=Rmh5r>i%@d!KorXgF(Fc zEg%^{#U5eLmzz9>a88no+{Xo|T%3$;ky=vatv;4EdRx<hPc=^_{`~ls-^};=^s)RW zPr`2Chn#Y%2<e~bUB87?1~wN4OeC9YqOz6UY}rhMY3)|)mI|>8s4vb*jL=4p)=-&q z<<ZfPcbj||IK+(hu-<*5(8j0Tu^5ku4S*?<48c~l=H<PLQ`vc5c;53*fr0*bLk@X= z^E{ndEk{4BwIuCH(EfUwJPSOdv6J2Wde6~D?Hr%~{CmUxCkP92C@{&Sqz_C6Rxfw+ zd+=_!^8UfK{cm|QVDt|>{=2$he@a;^@@Sz9I$nnj`5lax1j5-nJ12Bg%R78=rZk7~ z-TbwD!!_FJnk<54wVMqM$T|4mzg^}Y%G-NmWPDmtR>J_s+??4k2|ALiRy*w#<qnV% zupf9>>*79cLgk;@nwJ->JNnyB9<}wh0W0Mj<xy088Hh)q`%Y6PCO;idGL|K&)$BC2 z^bWV;w`GOfwhi24a9!WGrM+?W?n<#<@3CGAKf?IaX7?ytEZoqdx$^klcG>>O6+730 zh6eJG>S@&$#J9rz?dEt%7fhaipR(0zu0J-*f{C`**}(6200o9wpyQf2x?!f#TrO65 zv(DDV;*GyWCG|yKBd`IKi3Vh4EJ>H~!n7|Dscs*9I^!oZNJ9VqU!s-hNxs=?M&bY| zY=Oq)<UyRaxrM<Fel83(mlp;7W2e^5h%rZL;Ri8BjUPUoqt=Er`bH`g4Us!qPmj9k z+kjrlqS4nZtHf`{PSvfOiTBH0umzY+p+=I2N#+P#*zV;X#iKUgFsc{wxy@8h!B^x% zm?l(B%`l9Qv8lqCzFy{JUb_vCpucF&E&AmO!y$FV?o+cwweHSJMt%JJm35nMdtLGW z#|3oH>jjvm#oK&J>E&1EHF`PQAm(UnYFeL^R-C{pi+fFh#S6a*YF0mA<#^Rk>mIV& zo%_%pXzRXz1>3%C`CT$d&@9R{ri3<1C8w0TuqIUE?UjexXwLg?&7JlEg_ttqr}g5+ z;KRi$ctA5_KyoiQ4owkG2B>&gH!QF>)Z_u(O>QXK-_Sx;7Mzbv8&ZEO_EtH%%_%LL z?*&h)v+l<-L{lmOA-BO3)bUby|G}G1$u>q#7RNu(w1%);DE85fVL<L_jKvmcaS+uv z{<z&+$ao*ILhEg}u`2Z}OEjVAUCnJvIKacd-N1t7)ML0vm~;;=PFZq>&-$n3$N*#K z8L1Njf_VHCA5<R6CW<LY`ZoUGv%C>Q;Qi~0x%>)UNj>T2hvuc{L6Y-lm4ia}J2^Vl z)Mgr?<~46i9_(LSNwdoe-K;-iV4sXi=N#G9LA7o+SOp&y*zgIu&RBshkaaj<R#@Tt zHP@v3)a}oeXNGPJo3~z>Uea)!_-8OW&^=tb_(if<%w9aEdQE!p_3;U1%l8-WRpp@F zi`=f|yJC$dwWlvOU7<A*Bf<ll*w)jBBMNQdQCB3tj{`l5+st)#<!8TET-!%;5ALS( z1T4j4He6867$FUQU|yTMRVPtXD;%e4)IT=?QqJh80nryV+l=IH+-eDWv75}jI{<*8 zd`L&!gUnr=2YIU{oDN|hw6onkp^}Z#0osB+FAe^X?Ff#Q<>i6HC6<n+x=$yH*<q&^ zezQk)wy{6>k9r(nU}Iw@tZkg3uF6qrUM#j>X*qg%z;E`xkkG@^44+75?n2n^ph}~Q zw@l8PbLH{T*fogUp6*PE6=%tFEl?R{tMb0BgW0YM!&|evszt{}Jw1IxC@((Sn3x!e zgV}Drp!E+7ViNsdF}5>MJau<Sj7s77&Z59BZh4GFptwr>iOAYm#S{Oy?y+38&vg8O zt9i^St3PG~aeHeC$5QtmVNAw7TB-YRVU#r2{BnvV)&Ums*0y%WxA9;$XruBGY0B5U zc`RhWsvu(a5Dc-fvp84<idySw3nh?5s0L_KVwP}O(@yqz=2AHP0S}Y}v|&yGy~09( z&>SZfH-Y~5=}?w3bP?z)i1piDLY=tqt5f@DGiJ2x;q^Uf5m4Ac2Xmat_OI+2KUKoN z0w)J!y5dcHqk7#vX~g`%t)Q{O`(wsnW1VyrYA3{UBy&qAy{2Oy$IK<KXLn_$Mz_Ag z84bq)>?L^K6SQ3>J2z<`mFY)@XKh8wuQNVezfn4yK?hJgFR)j<@kJgfU9To`&@S z;tzjZ8iB0OYyKD_oUi@^a=J$jCZjc^9PV1>=`^|aAR4{X_c(LXgRaW^m7ULcZg|DX zzj>AmFIf}U7xMQ#UCpdBqPahT?q1v!vq^Ix=?;fg)4hiU3YwMn1A&B+A|^>x^-PIj z(fKH5x&H6@l_zN?fDG>};%r)V&&*@2P}^`&bvk1$=U_EB-KQT>RnS*bl!e8WPQ$+j z%~Veiy7GxAehKSGeaWKW5as1@LyzWV_L-xTpsh?f`!6!R)}mjOIjH+M513Lwg-upA z1prD{e0&cobyeMvJ#VD3=>Tkg-U!z#R<pE>2w0nWsGTZ(e}(GNN+}Cn4{Q0Sxv(cK zPXp)o&_nU4$<l6xYl7W)m^WyxK$8D-z;ABKVV28@FUED?b276k`MIik^v?PB=xr9K z7etL0FSNv4j(5Ux1?!*&&rUZfTZZ3rGX1AUMUoJ=WUNdjX&XWj3Nbk~+q>J2V|#03 z9AdAEd&{HS_=Ercyh+Djde1I@bEi+tusBQ6ugte-3uq>5%srZcgo-KjU1o#u0A-jN zfJM5h)HfS=xCf9#6sO$`lG8|@MjT5X2e`$dmzDSu%k{Vpz^h2bIMtKGnEo;Mp>)-- z{s6owbpG47Z)Ytki;r(i6KN$~KqGcs{==qKx%K;#fYDEaIt>evdO}4m9v{z2`q#@s zZSek8r_rT^V??dnnEm<@o|#{E{cAWAmtW5vxDXX+F@=pwB?x^kExsR!IRHZZ^|BRu zh7Oaz*=J^r{vOMzt4qtGeK<wRnqI=cn?Q_Yl5n+F1_rOC!{F+cuVd4wTVbc%zHKxY z&!Dk}m!&22USe>(RE&BYPyLa7dS>8CTGEP*3smUdJ$Z-mV#Wc(H$D7eCjk?Q_jftO zqzQ<&t%Ja3fSI|>H)cc>OtV86QvUo=h^@4l$XrbLOzw%Jll~j6>J-1VJC!!UYnYud z;@U^uz}0R6ohrmC#BOd34kd;&J^bB3?cQqT28Qt{m<<f3eg7z8<bu0&oUxW=K)})A zCzhDKZA!2m^iF@FnQD&+w*mUqdC$PU)*ht?F4Zm*oADa_RG?LAIUy6l!jKv#oNrO4 zuYpiaC}q8Ji0i+w=!Np`#mk4mU~P*Mmp1_`pNxnZw$rzXf<1Ox;D*6Sbg*v3GJ*at zm${*o>U@y0VT_`c|3bvtSkZsOdIGRiNP7BY4P7kZnp!}BaDrl|(IN`Alp+dN@D!bk z{OdGuF}>k%Z8tDIl(S#UwOUk2NNe+N__{veOEES!);FDMfD+I82_K-d0&sWzb|klZ z>xS%^L#|kBay=pFfu482na~dc@@{~?vj7T~m@F#U*=$$s9mGo`kBp*mc#7FoOwKVi zemC~uw0>plyBn=PiGbeMj60)o$olby=xq3?f!u9tDE0I1Zc2go#*n(XD#@hA2(5o% zaLshs)QdC!%X4VzPZ?W_v%B)Ludf%&%FZP=Tv+TFK)77#h0&!ZGivtEfHL&HloLOE zRyiBq<&PZ;Xf`(B`oClp%GCj2m~R>NKUT^J4K16`Kr+d;f-*|tKG2_;0AHbmSj{Xf zXCL@Um0v5IwT9k%Gp?mRp?f|Bxh>OuAt{e-J~8&BTJOKSyYh4!(Nt04W@8geiX-`A z+1UVoNmjzj{xvn<Wy;{s;H`44qDS42XwTS4iITzkrluyq(Fm2n@?^`s<~2`GCOZdC z=mw@Rev+vKP5=GMb--EtpMOz*!PDQ=$?4HlX}QF3hOKAT5&N;qTt!t@n}R4&5b!oM zUFkx&qD92RjiShC?~?xxauF&i-3wIe1kx@v3@lL$)00}0b@{<2PPIhQTL|~{NmWgf z%vED5U7fy8lfHgWSz&!ArL3~B``{)IFhG;=KU45%dRVvNKbKf9o`VM)8$pOVl<wb5 zFOo2E-LH(6YXEAo6fTsyaOb5xRT^Dk%IfoBTM#05?A<az{LrQVp(&e?U6NF-kx;V+ z0^VR(lL0F-N@>$J%__CdaT2~1vin{bBv^Yw0|LsuDEpT&aW;`lcVEdOd0Nxn6L;YX zkPCyI_lNKAq~h$p{^#l(S5pu&&?zmG{YQ(v3|5{t)NJWd9LgzQ`%L}r&RB?Eki40p z_OVs^P{svV|JdDa?1Fz7TMfT;oJ}fojqIiWES%nngdw<&#)q$?dND7T?)~X5-oL9) zqx1LQ3#LN}ue@F59acy#YDO2r)e}a!N=jWg2`Qn(;EQe-`h(9_m#@35R90yCk8coO zg-RMu`Lxm$rx&fhD^QXX>=ysE?K4;DJBHLTs}8YNGQ#|vXMZAMoB2Op7L;;{TfILa zxn7XHzVTd!52X5x6@GW&72k*N8X)(1nzrrG$H!-c?7YzS6TSAa`)J<Z-?bR0Hc}qH zrk4hO2c_INsSEty*EZ#8OPVrdh}REb&H$=`LjAROhN>X>tUGt^oDCEt9d<wIp#8E= zLVjBki^I_WkI=^U{V{0e5#aR$j`1G2Rk!R<oBH=Zc|o)H2$aM^BmZ*nYz*&S_I{cK zI^eS%L695&<rL`Ed(eNodeWZz|0dZAWsuyGyo*lE*)gE|JsX!+<i0EF&@h5bhw$5n z0B=9xLWO);XQz*VKh4QLvZ8)GETaLnNpz%i+e$kOr~-j2%)KnLCKmNS7=0^+mzBl- z>Tw-P;EU^yCI*CtSuPP(UKyA=0VA@H1!Z^zsK8Xs4xW3A2)r>Cg!i8sUktxbb!)>5 z?@>ZHi#trTz?K%7gj+Pfm7U3E?5uzz0mxi#z!ByFmM~#+>)xTB)TpT;di#=Q838`c z_3AsRd!xI3LvnCLGvP_e+w9ZA>eZa+=n1@GBI8l85Rl`=&np}-xBj{Gv14{;r%L== zv(fCM8{+?%Sb*(7o`@$hGAeUBqoX{j%B3JHQwyX7DyP}o3~>E^M`kd#Y0u6H8J{zP zLecOmd<CTr#`<bBxPM<xxR}0r#Yvwuj+Qbc4=`L*W@A&oo6=iU&^zSu_~OOs7i$x? z*M((<lZD$?aQEpi(4V7uAOX5jyJn6G;9ukBPkH>J)l+`@#ZAM@o+Lt-qDG2P0|G)+ zGgpgl^s<tkClMw%U$!y@vNapuAnHQT-`F^Ib`EYCef9EFpdseHD3BtKk>3Mo<%M<g zMsJA^Sy^m2MUNHu3kxgr#j)S8<Ta7u^blZw(*y=b(9(iqzSSDa*zc{*HV3YCIl#g~ zyA$uL-@W6He0-h`So>=rx*3ijg?zimKX*nA1T>IB;(&pqO2lvO1_<;?dY9#~R3sHv zm60@l=E!V@7FvOsTfRS7-nlC9<*nfim|;GyQlD&8?bz>QNE_ZFjn;I}lJ0RV{~5Fa z-HX>St3@?#cP;8FESEUIWFCnBbP+XcIy_K4kr;K(bf4)unLE96G#_7-S+jOG{rc3# z2cMxCJA5XiGz+l&0acyRRad-a{sW>LV3|IA_(1HAoAdbnb?MZ2U~s%~^Wpw#UQGY6 z`55;&PI}a#m)Eb#V{Z02;-!*cMY7orv8=w!@p_0>ET6(C=&Mh~=^uuePYKoqX#~Fb z#nAM`+Wa#oPG4{D>*E(*(kD#i2m83Kk)ojhziFG>H%sWEI-C^xsv0T*QC>ZqgtLi? z;tT5keb;K}djY=y5?kw%avYAUa!Tm$V1-AmHy;3GK&{(M;3mK_0(gqTA8b9ILRipB z7BQ`Jj2{8<#o<#0VpqqChD`ApwsZAVUmPQ!bg@s7jw+Q*bn}}v<>ZZy{#fi%#u+qa zM?^MmsyvN+yD?f&TD2D*ry9CYkh0ziKG-f_=n${}Ll32)Y?gNbCO_xPu&}iP@mBJS zR#6cVrIp_Tq}1r09S5LySkzS+$uxIw3FvnK^s6(1d#y0tIi_SiY$)WQGl+w`to4Ks z$b-6{7dNmqyi&fU#-FK(K^G)+^(P&wakC-decME3AKhuj|8W5hs^niR1@=8Z%qPm| z7`bbHl8L8#W4c4lhkvHSPP|kW)VbaL7g8nnRXrMsWk5eXdV+sv;fKCHTn`X9u17mq z2A|m}2M8Ff-LfG|$?6ktsUu=tU?H;scVeCKNBAt9&tlA?vgVSsKcDU&AC<Gl^P4T^ zDHuw+?~rkI>nAuVDQqya@m_;H05*g}F3g=C=8Z@M1O(8dhm=m6^u+_i1CEXYizHFZ z!M&FS9SuvFF7gK*Z%3}*Ps{d*(s#(J_g?Nz=jGpfOvh7Bt--OgvxNXEgsrZM(;Upd z$Wvvd_cBYY{N6Bt;%nSz$at%;kUu3wCs~RU|5h&k3X-L608QPPPhJdWb9DJ6ivMK3 zgL$#}^94nnrsj$vS=_p^qaMJ?v>cL&MOgub!2>;dPY-7NRsm}8R4HWq9{#+J!uBsd z<-k||+nv=Z&j9DPW`boP2|Y0vJl3Fr9N0(@ByLy*cCzhvRB;e(`XFUrj&3K@P$^rp ztzl<wn&Y)1#m$z+Z+-4GkL=$-LGHc+Y{2rWG1GFF|Mlgl>g#5yM&i%L?<CD9j)uGF zom#giL3B?4K80F6^j$v$K)AA-u?98t^sh~a@1%Pp;QMJMZ8l2T4@&o6TfW;Icz>ff zKQ9cPu)QQTwAw`m5HkR7-K&0H45N}JEDmN}NS$b?p04}YY_oZl<2FWdOFqb(NLtOA zpswLzEaJE`b_Zvd@}d0+^g@!Dv~{8T*kB077y_wvA1f>_|GNya$~;;t3e5l3^GQIx z3h1BHw^}{)jrMw7P*i{l=hVanR}REJTrZb(!--io9p?6lRc?McFe6m*c@IA}cdeNs zjE+X}hwMekH=pL_-a-!;?Or~lVh9au$>#Q9l#QQse3R8KkQt0}M<mlm{X!(mU2Ux2 z@2|?fbIstsWPB`F0R?}&j{~#Z{S^$T2{@tM;N6Yd=tQ$2f|aa9<7P8H%cF(r`!ws> z)?UA%uBf?BqJ{-7k4g2og^vGtdBTd69H;W<-=MA5IWjsH=XOBMP$29H{|y6}cmQwP zJ+_>^FPNDx>SiVlTc1YFU8(ddgYLBViM!lE&D8Hs322<`4yo>Ph{&(chod8!-%QIx zOLbKiE;N6HA8coBOxniY7rE!Z{z<UP%eZ@vvGMJi_sUw4@RX3~I8(w4f!&p%Faj`> z%FLjD%Wbe`n^_OR9>M*qBjPPPy~>6~?zo~jc8~QtDMkvT_C#Z3)zZX_zwJiKSY%GW z9=nU<OV-<oP=O)|$0_duTh^_u*3;P%E7cIo{RGD!!hiTD<_`iBTxqHo!{luv-9NHq zwWwUDSIEQuJ<oi6{8^lf?;XXpnFJ6b;4P4A>YQF+rDVEk^>gPBEtC_At5)mGgeg9M zG1mNtcfsypsN!aEvWD%QBsR7ef!M31L!HGgz(ni9@2(;6S(L3);#Mm<5(Xu&o%)Ta zk=^=v1y?Fd&<2W9y9v7EgOj>^hJS#cEK6>t=K%<KmX7a`8mGF)>(&16)!H=Cd}IXs zT0u&cQMHwhtr!>0ms$x$*H-FiftDZEOG6Q)rKzd;IJBh;UaAk8a>uzr?(pzTe8uE+ zM9qaxSOjk6smG2xwo{pv-vHK+#AU(Z+;bT^p#@bR({|e|d=>0Ysk_vZ2^C5ycEmy1 zwe8*0i&sn#+UY}K<h9KdOY&?63j*8ziLx%`neO(m*5#mwS@jE$J}SyVe9hf_=I-S` z|F8)c4rPTbj_Gy>-4s>NSN!%CG}{6}^~EXsB5T}2R;FENd{&lv_(LcgPEVuuXWow4 zcEwF7H@g4mwCm=PS|8O4f^PBc{!|w-t9CA^3)*0mE5?POms7$HM>NP7$kemt4~V-u zMs|;=63AR#z_FLCxT)u{R!Bsto+KKst3vQQ8I^+u4r-S*?r-*w=`@aJkY)Ubl%M7d ztEE_3ni=G}t_62y`e8MBH-Bjr>@`mC1x<%+HPeF#h!U+mV2LB1h}KU2=`wfMKR4~0 z?iQ=GE)ii6Ll%D5eA2}Y3*C+o5^OM}nS36RwE-lEvCT7HUOcthO3RN>&B}qQTj64p z&{aHoL%Wh1avyxD+cI!1R&hQ1LHnT#sv3|DkMO9v;~iwp<m=g{fan8AD)GgdnJOfX zy9n^_5iYZn-ck1rQTic)M3^z72EY#-0St?9WXU9S`EWas6wdtc`POQj>S^%i5gvbh z(#SUkh{5Nhn85Y+op3NepjGvunhPI%ewep`tpXUZ)`J!byNFpnx}{G?(JA53vFnN` zoDEi06~*WM9KkLcN?zvBMFv~|EXv08TtO#C`<aUFt<(d6fI&}K5o@|s%D7c6ajDgL zBqDAiy3lvp!sfc_=}sS6Ind<5(ev5QSuV+dTMZ7>2M&V((<uG6!g`^7gR;lzM?wp~ z#EOtU62!WLW!X@&xHk!s9F`{Fi6jr^>9A9|*+Al$jGPu*_xJ*zs@ES6Kut`qXIe%~ zRaU!0Z4+jj(TidD=7Z9M(XbQ?z@Q*SdwJs-7pH!X35~y9M(F;hZaxa|2(moae@KQk z5(gED;9$PE{X~dW*h~vRxwmdMQulp9##nuA^8z6OCcF0AsP00zMGT{E_=^{Jd2zL; z0fRi0`CFYuiXP3R=Cv{Yz@_*frw3txQP*JESe0BZ{d@U%*MdyqZa6&=+7NoCz->aC zB`Q1j3fXJrgKs&)m`AIVrDcp?4<up;2A=>qN2>zg_TJgNicLG@@6XW&rfu*8-JR@j z=I(Wmp@TDYR~*OSN!R9|#wi!th<B7eWSVr7lQE@tMUSw^nJm7;k=81mNzQJ3*qJH~ ze(ATxzHV;%Sn*h;W6xzxw4m*#yd-R_@EY-aoJxaxt$VtALrF-{xpK@BER0%!6K^3C z04}xmoadj+_TJ)sTj_G|)+w`Sb!H77Xd?T|0i*v`oQ;M)G3RCJI@|F(E3tVi&!;GW zO#8VWA{2JU=Fg?h83vNLQO(A-nr2%2v3Z3nFh>*UXh)(JX<LEVp`%FJK<uDom70e$ zR+-XMV5fj?OMbil*hG09VOj|!HiG~Yqm)x4+P~nNr<cuwthooZt%F%GwW2ukiA$*) z5fgSoJ&+nwK4^u7j%I?vU@WN0W_5A+QbiIT1@ONqmH}#3;09&S+L<aRCy)^%Nd*wO z->i-9Z(yO0bC%4Zvy!}B-s}1rP{O#=sJ5IFtFG<9(~Hcr{vC|FySvpp-1ya-px<Ao zl`hutu}$vrfHEX}+%b9Gce;7<u1l=-2+EoIS&+O-0SC;)*0DhB;ePF0S;+A=6VhB) zVXKMM_;!R!pGMyX0~9dikaF!?v{1--3;$nN$=_P6dlPY1{x)`h22CYWN7qtFLQlo& z=HCO#1HMnZR%Pur)#Uwt8Hbg~UrOAR&k%b=L<&#HUctc}Y9i)f-bg!L#p3krC6tHC znTk*YhygW}<b@VBjyjn+m^I|Q37|1TKNZgp#g6|BJc;0Vh%8Xz`_u?9!DAcQAD;|r zdXXJ#W&(*9SO_9qePJ6ha^r$yx0Fxz#zJbay$x0jPNWP#)D0rt0GCW!hcoWoI#apX zA2~gv>3N=jMHMtmpQ(8+>LCYnp%iW9!Ne>>m&F#!7Uc5wp>15qW&}ru%U#ZBpRq;O z3MMYumzgzE8ga&_q*AHDGpf5_vCAoYAwxgUEVU8?LB1*(Oyl!B4QGbamBeYz%q0mM zJCcNQg{km8={Klmqn`SGGEr{UG4DLHe4B(zeNooiQ5RHe=!qIfh_VX?XroOQp={^o zGG&onnSf^nb4?2JFF6crS7ov0TNKL+jy`IabQ;TL-aY{2N_WJwQnP_x8DU#Mb0VDH zkAN2@TIpGb^$OV_$EsbL9LE0X0*XSZT&~XkGQi239zQJ&Iv`XBqTzr%%|n~NsPW}e zYIkCp*nLwy#IfDLTZ#U+OCk$mRzKPU<AC=TL}j)hLqWURa^SK?n?KJ;R!O?08bTEW zAIQbOznnOIH*p74r?JFfXC8NDeC?cBjk8^Ibnz(Z=NHb)Om4%mJTJvS6WQ7!<7-7g zN53*slx1LIkWoxctsPiGy_9#_d4nH!@3=D59dkWLR4?KH=#D2DOIJmQPFB(>e8GD4 zPclPi@^oJ`*6+k+;bR-OWc&Xq&bb#eqjUtQ#y*T~==!$aBh4%ZGlbSpQrUgOyE@;; zbDl;uUu3MX)Yk-~LeVl4tA;GZpV8*nIaK39Bp@vk{)xFq+C*Bsq@>(RuK8*ktK?0f z9tcyrroH|s7iqfz7P4c5MIF)6+|{>NW=sGV5)${xFZx2<+Eir`p$zl!^Do8NUBDvt z){wEvX<#mJG3KnVq+t+<O;wEL+TcoZ>-Yg72)AW*p_Gd&;4kJ_BW=4!vT`x&vx-|y z<ek2QX<KFrXH~n5YY~@6|DBO1&TU%hfBMs;WVr0+P$^@@9}Q|rL;D*zqK)e9jGA)e zD5l;aE;UsE_`*U@jsWi+({|h(=K7y<>cNEL;FK*e^a%^BRVhU#yY%sr7OL(K(r*qN zgqsSE+ukEg8J@qu71^?|k`Cb2+U7X(%p8CQ5Q^3Xu$T)|-_4FFz^6ZF+ZC$}Ud*b- zT7m|iD7WjB@yWfUmCw8eIlnK_%76-8iZ=y}K}V-22hMx{)SdU84OrM&PWi5J`9#HI z_PlL36V|;k$ST0@FO%YGd;(8`e@f}ADWq-pKGLlDdM1n=@_kT+jXr7!_P~yiDk-GW zs_J4){ot;Ic=4i?+lI~H_@q7e7L;Yf=?4Rc`0TCFewVR^>L(*c-8`}+w!~1hp+023 zUD9^EGvIK$`}9i}H@Ii~vbo+w1}?X~y>rB~4tV7zS=8K2?Or20>jPCQQ8!|;XdjR5 zKfcN}8V8&BjF1{Ag;PWHRshhQWIkuBHo8#BpZD^0{_`1X7`w>oEYe(g<qbOrhxljT za_8Z1!GIbUsUTpXU^wGWN;VqeS8~)tSvDKxgAJ-z?!k)+RDO^@lT$N>vfCH;m{qIn z;}G__p_5WWswj9xRzQP-bq=(y`yGw?wehu5PaYYFss0GuPHRSw#tkDkH!i=Vd*_*Q z8`C}kK6rR&i$7mQml@oC`Am|kh&mTguvn@Cyge#9;1GM{ij|)L6rng(su8Vkb5+(- z6N7kZh&+rwT}TXBdqO^#_Jbww{@cMk7Zj&#^kP&(J0_kBdH}9U0}4O`@?)WQ8m0y_ zRD*LJYjn_^88&Ym$(R%KXr|V@2!lJrjeXjNbA&=wb`BQ_>@K{PoDR{AHnCp!P$n{M z*3fF-G8pe-P(}X#aRHK=y2WP6&ME}ol*Gh-D#b&fWl6Y=TpE??Wu&Gb6S>+`UE+fJ zfq66(BMYYrs-{Oth?IdJCU4YEsh@`Yjn}ApV#L@uyKN7PHAZ08FmAA);WE-Jm$ihA zSI<|&G;ldJx-Mp=57G^aRsQ_)F-~A^+aUn<F$Co474CEo?eoFdd1U)c<&d(R^@Igd z18!8T>vPf`K~9qi+Q*oCAT8DbY{Boh-3bqMdDV|KeZ48aoh2pN{CQ_}-_+Q0R;a;E zaQ6NmW%Z}mHF$heWkMj0Z$9uAbyb!qS&mm0^;YUC@AlJBwopp}RBbu5CDvatwq}<r z?(JsMnZeOAXtQL(pd7g8Aytg4=uE}IW@e>qHMHN?{fG`Gg=Yo&on|pT@C|TyjaMmB zpS;yn>0HFMtuF;d*Lj&mzNjRW@gPv#j0$coQ}$eMC+n?L85tt4ZE^%UscknOOdDzy zK*pI6$#3-EK0{rMK|62HN`*VdBx~E=GFu2N3S#1!toOkmZPPgjS9$jrsuRO{D(vXn zFmzX?1-Tqqt&e|w+D|ZNE{2zumX+y1FR_6t7-{OZtE5^F!z7C2hRhqPq{+>>@U1_N z4Tren8W)TI-W-GShFbclW9$rzVpWfpyZ~!u?+In@xL?X+TqZ|OG&Eh#gS<XP+22t7 zJM9xw1s~zo>o%jtJr`|%b(t4iU8zxxJW1%5{HI{{6Pd+t#z%Lf2psT2wS!s7^H;ov zC8zS8LUM#mpp#_Dn-Q@<SD$X6m}%2@n;GffX+_#bLqD^NmU6>ozciAaDZHE|2&x&7 znj*oov>ewpqoMAB1)s$-6<t`Z<7WIIbH9(QJuCz(Cb*qu;NX6%DF<tVA7(!mHgO)c zVd&mhg7H$ayq@p=A8&6R7uEOmiw~kGDxv}kL#e1JC?MS-3Zj(K4blvabc2M_BHhxR zLzjSnbk5M-Lw9rc4Ep&#_dfUgyq@2^zu(V)JnT7VpM7?$wf0)?_1+hv`Zuyq6qo8U zK4?hLD;uY1?;MA+zoi3lZ`N;lpc^{hZ+<w)9O0Ih5!l#Z3Aih1BiVG)vN4^ls14%N z(iRok?M&tAiaBmQh4hu`;W7$4?kmsp?j5$hh!d+mJGF;B-sM`WDmik&vfGS<g^bqC zW!Rklx(n}L+;Uty*~>2b&O(83jIk8wn7lYEnAyu=GFF+6&zqJJcJ*xw#}WcbEZGHV z_^=hb=6w9(=9RRJ18<FJn-R0UNNqu?8&#Jag?7EaogR!|u;>1Ib3TA15t8L}EIb!< zpszuRZHRZMN5Dv?)K_`1)en7mX862atnX@sBDPhGvP8l7#7{fxn2JyNo|oVWIXQ5X zrGBEC7)=A{j(kWp6Ew%)xc;kFP}$45P?O8JMAX@S!ch@#gF1S{6`17ui93$BrL=EX zkb9R+H#l@~hI|hp60(j4iNr9jbc<B)1LEh$9En0z`#tXp+q-)jxWxT*gZEM;N`&^% zU>8`spozgd^QQECxoS2?yQ3TAjbJ}_A$=-&sOT3gg}uTX?Jt7)ir`8fA-f#+V#3!i zTm-1b)R6c-gA*w=W=eL?)SaO8$CS9Wjz?pkP}yH3B+{z*qoQ9{SZ;q6(rlfk)Xzoi ztjQA;?^G|F{LWZ1h^x_|?Bu{|;P|aJoxSVMqRKjzmdO0LU}(d|K1EzcY=6mYx@heC z7A)Ex9};=@f+^>s4<a*GEE@YPG{P~R4nQaK)de!ArhY8+HE3tbS^GQDTQPAtY=zPF z$1F7$s};5+**LWAZ$xEPtcKU$r~nSpkaw+9jsk}cJK-M0{vi@}IfWf`N`?tY&iM&< zdduiG);V|52V^%gp@|f+)yn-o9~RqqmrZvvWT3Kg<ZZlL?Wu8&#mIO8sPY-WF4WKA zCTHo_LsRAa-t0XK=X2g#5HPl5C2FDYricrzl`GVFUA|B0oF!m<=@$Un(T!07x18Lg ze%@ijM2-FDY~v%b+&baHtWf>r`ws8tr`-d1Ka5MuJnP@f>c*-W&o$gSzDCdPbwET8 z=k7QRLDY&gQRcdRiJjkkM|ZGWv9^4{zok%7A9pNWBjvD~YkB=3l1UsUpYKq1M}Ipq zOzrB-Y&u(3&Jw6MBihR7lU}W9G*sY3+;Ivakj=^?hg{$m)h8iWdm~D9spSA+^a$%f zcZ?e~qub#&Nq2HcZc~ee9Jfhy^v&VyQpE0Xu&K*$G9VvH{q`a9L#KJn>S#8{rBQ_H zaBPO;(O}=xwf@4?6X^}{svO0yQ?8!xvtq5=M1G-iDpnVmIvtMFQ=_S`_cof4pQLdP zB*AjZEZZ#(*wYI$c~?PnJ8iaXzwX+~5w%$KKJZ}-TVQQ-M{Tbf#qAb59_`Ah*ewbM zaDO1-ag&BWb95N$C>i<aGh|r|%_`TExh>p?V8<Cy#5ZaqHVjWy%{-w-Y-fkB_*>2t z9<D0N*$KCBAWkmhk(al#4vSXLhN{iA4O*{HIFg4+nezsys%-4L3RC2-y}!VA`)xJC zs3FoO<&rTe&*h3x#MOD|#lA+X{)R8mMN?jR%C|Muj(lfRKERF94BhuRUv25X7KY%N z{a4a#^F8mOMxGk*JvR3&F6=x7z>AgMiw_6P4ETn7W?NT+dyb~m^6o{Dr|zoihZA6B zl|c@x(VLvmrilKJJgHXEqepwl^<E=?nxaNwy3+lRN6!u6v!RM|y28S=+do?BJ0MxP zxBVmyhbl_Ut8Z&gL9!$SEZ6jVfxpo}YzeO;pypD>XwD1VU$?iSXU=v-MLV>w{8Ffg zzU#ST({>B&q8AeOUQ0A2uQedYY^HbE#w4|!8oGr8=7)A5kxT$8BrcO6$$rQp6~*Gb zznvhsH=q-R>yO<wcC^_FA1dOo9e1=IGAxwZ8+Vq`Uqr<@DZZCsnXXtnG%1@sJvm&Q zccb#}q7ufZbgB!yzoJx9TxaBVAg$<|(TBZWm;8=xc?ixP+4iV&EzYz@#JY05&O<6? zdvVZ0GL9FQV#IW*)2Q_Ha7k;#ydH1&x_tfhVUy||#Bb{8c(oY4*gD}~eo@9{+G_&t z#x(~zqh(6l6SR24;}-KV<-(&4)UkFg<rYmV#Ye#F3;SM!_TH1`RLRCXl)*JFUoF=R z;CyRceKclIlQd%6`rHo3l5kl!O{|_E*rY~f$JKtV(qdw@;w)%gvHFGDyKBy)`x}WJ z?1CO3H@UUZUu^w4+qhE&&2xgB$`9A90Gw{vk=JsoWWcb{A4c&kmF);PmH~V={_>gu zMW=0_hL<A<Ch(oEIk)JbL1F3CF<RCqGUkKD5|lhW5#lhLdEn^)p%IzBig4l7cRq@8 z$&p(}Yrvg=zzX#waU|-HS$`k<p5)brNX+Ysa#dN?YSry4ot+?nq={#hom-mr=Iq37 z*YKA)Y8LI9%7Nb~^%KFq$b(Qp+R^l@z~3Bp!J>~1`2jF#tz?{qfSMCUePT86lXoQ> z(WXxw)0gjfgd6;rqlCoBuOforZU5Q`fFakgSkH%drl_vrpmI(8YV;=f;cyVUp`yPz z08s14usd3PgXFx1;ZDg}jHa~^7-O024)D;D&M9aJ2au(|(TTZ|?I{kzT|S2Zk1L}n zt=Pqy(ow#Ol$WS=Aj&>SI9g8JI|W-dGUt}3y8#b=f`HRnG;R&>?xhdxj4L-idUtw! zv{ucvy5PX^+?}&}90Ww%7z<{{r34=dB#5J4@kMPfB|FowKef~|>B*3qELY0HSNTm% ze4Qo-m23BDZS^>W<sz%$f@C&-f7{9tyzsN&{bU9Bb4Rl0;Zwm*$$^D2wy8@_L*{+o z77Dw%VkyYhJ`EOszhG*V5wr?YK@b8%yZ)Nt^``as?EFd|JDm?K0=iVs5W^<pJeEs1 zAj*=>F~~LNPq4;8U~e*1L|!sJcG%bV(ZR&n>-REYN0VNS<fNkJcl;cVK6(45FMEat zx;QJ<_FNVdf@K@R77t1`k3HeyST`-2IMi!SiYiFVcHc%>(fYZ|UruGWCQJT^!a7`# zDUR9rO$~wsU#pO!rKfgy*o&SPPu_BM=;}C*{csdp3E1k=uBwQ0I$YepMaBE}<7trW z_t5##y>X-w-dYBcgy|g~JmIC`2IwPc&8Ufo{hl1(3xha3l!kj`y9tyYGFY!Fvs{o$ z5%13_lG)BuscMg5Hl&Cl^d9fTa?wfPkaH<qbL$9FhMxIM_EI`KSkATG=UQdYlZa~? zcPtSs>e}d1GO^{3#TquWSXQ+^VYXgPXB)}u0$TKR(u+ErAlCB8ev9n>D5urP=<?zB z?;$FC1DZtej`f2$I~kX8kaWOPGVU}q^Lyz`<z&6SQ#8(L@|N&%q1JDyIai^(ViAnC zGtGk*bI%y4<q=M%oAgP>u3O(brhiC{5_UHJirC#<2I^6_g=9|S9Xxku6U4ye?~0gz zP^;N>&3%z70ZSg0&5TWf<~o*>_cts*l3Re&Z`riV$YuNTDKXB$s!kC~ha#KsqQ;M^ z?_}xi%585^QTIna=DI-~YX3tHo3Tx#tRo|c^TU^S6`mXsyTDOT>@?welITS^1|05i zsll0HW2u@I&o2ZrjR#w~HGSIBxQF@FaopB@Pj(!MT5G;g2<C{#x_#wLms7K7zf9?r z7VXeu)|6c{X4k?h!F^IxK276XwSiEQQ*$b#$QoOj_gB@q0KHST_Vcog`v;n?IkVvy zYei~4i#xP^`Lbs6abY_Wj#G!(g|D*mjAF{?au$BN0%JM#ba|^2HZigLJGg&TnnVli zRvR3OgT=YTzM#`NWM9m02TPp7R!6u}MBfX`CNCCtuur#jC&paM5>xCn1<?swV&{#1 zIRuAazrgs%t#&vC&KmG^Ie?6b!GIc|pPlx{&*<3qMkr9(sox^qTDIoI-|P9iw?*<O zWvWcBM;CS$*ZJDbnB*$l_Z6|M3`277<nSu@7eou^b?)y^3*O*ds0UWa*5G&Ctz!|# z!sWbCE5w)8EEEJrh3w!0mY-0ZW$0KWtMAZnVeKb)gHLSq676R4o0cuYzc3#AMlcbn ze8(Sm+8af5<Lrj8jdjgguk3FumBPr3K64yvg@3UI%aRvhaGB?li4$X9V$*T$tqki% z85Ldbx2@x@_mv1Sd+_ykDX+I5+nBrw&Di_BUcUYKPJ|N7r$;B3r;Zn|A4K8H_Ehav z%qKmD2<5Wm%}_Dc1`JQ=={qB5?e`q_{5Vgx<D3qF&l$b)d;Qb9KZD$eVuSv-O5GOp z)$*ESui9d!zUao9@e18iv=!xZ-y3Qz7ru5JJ+z|8C0@c_5k3?_9p|{aEhVwilirKZ zce>w|7_cOFVm<g8)?+JfPu1a{=t>CZrr~WGp|#WmK#A{RA^OV8y`8-FO>Ge!<iZtE zjZqH*@Nwd7SIZhSBRxqTvs|?p$HpJnOZ$ar9?hv(-Yn4MEq2@1i89g&Yao#PvhNzF z=*IQe7vM$F<C*Q6z;}AQwY$&6b=?ByyfGUl!L!lO0XA!?c{#j9Yd;nF>|oedqcS8; zAfGYg4XH?%dT-xl3Q4;fn-O-uJNn$(zJm_w=1vv1W9$&)yC|26xjHn2^ue#pUnwe; zv<Sp4)9K3-4}<d?*WA0Q^XF2tLAusAJMt2JSmu(en`E!2MF4eQ!EAy2tj{jZNPoG( zM|6HqtxP0**%k?X%y;Nb)u?PLkcTyUZ-%u$U3xyt?3K1S$UX&lN&tSY4rfxFn-7zg z^(-)X!WtHB*7B6^>@d@j{W>I`qVFtDlOlfpi;%3N0yW2%r|%oZh+Sf8e8P61Qq`Th zx?{`P5Vl@kk8;Q7K0Z~yV+yGSU;g^FVHcMm`NVv}IB{oy@RtP}!Rib3S`9C<o*YY< z%z^auqmu_%A&V*0@1@(Ypfb{r+xCuVm&&i37pAZgGmfXbiY_6tqa2B8An_!))HgcC zXzXcgEv;z_3NAeR<;w5Gu%Q3r!W(wJn!0(~FHBtJGEPqMl_PpZ{^{dtGLm^cm*Dpx z;zmASL&HMn3^@DP?4o}B(EdW-cmu+3rT6V>-j+#W_JuZ)ya#pq<iF(hvo;T|H_ggJ zC7hgOj<;#GWn>Z-%4DFfs+rtBU0WdLeHojZq53?6s<PcmN;1gbwE&C8s_~dE;z}(^ zenv*d$ow_9Ubl^~mp!)~N7X{cXV*d+!i-IuI=`M5{h88s+##aZaYlrvTcD`t1`U(N zLQ>zu<P=m?#9DDc=(WMph3U2SfO$YFh|tF_83bHiMM&5+seI3oG1xt*JKQ(|3Cx#+ zZBF}TWS(Det*m<)@WXuQ?$dJVj1wCDFv-(PBN>$$8TW4LY2SHgp;<eqiEs*WYPC3A z*8%zQq*(0kgu-DVA+xqsPA3;H@a7^>V!FS12(MleOjuHv<O@lEC8j;W9gvzAUmLah zx*+sz3k@x8gf>=b6l0!ZD(mal0Fl&w5-ksKnKR3X+1Wn8mZ*mSpDsso^>qrCuEM;; zV$nAXDXeCOU%#}IlL3YaKv6Wdeq{B}N$gU3&TQBm&#fmn(#ah4-?l)|f=0QFqc&!a z9E98zQp?5H$1gfiNT_COzu9@#H3=<=k<-mF<3hwiAdE>#1wF7i5s%*&YR2PS%c%A5 ze$}x=d8_5vT!b_~a)qijn3)GRneiq@>axB)eAe}%P_?beD|p}Jgq_J`=B+1(6-m+z z*VEIiHCHTt<1TBlWJ?-!R&-y+H{KuA#&a`_Z_qG6h!&GSR)>&s62N~$^j5k3tVE<A z`T_7>k^(5G^;o)fvAHcm2qMmR8=bK3VwZO7QFFb%FhpEQ_0b+075RpW>toU*TrVVd zej(Q_mtL0zbXE5(vKj;&jMy)jy5Dp}!ycJ$d^D}U<%0Tx`8j^$mOH5%3WB_hA@}4b zc<`+cA3$_j=29Ak0N4-0Oz$TfF`Fd2!w(S`6YQba2l;xCX4{>GT_o49Z?00WA>slj zJ@kOh5I~AASZSpRhd#ni{S1lDlI}HWDGL)hM*ooWmM#%Nj~nb73>sYXN`N)e9Jd1e z;NsIqV|{X0UQ-)s>eqBn>`JYONWn3w|AE)s_%|DLa|ceZT|sF3WermV6oy7QTu-?Y z{qTb6P@i|H>~{O!(Fw0w?fjNK6$WA{FZF~l<Az6uo4|>*tQ^w+=d0mQ_aVsV5t}CM z+oV_ZgN;<ArAe+ENadvEq+CEha*yJ*avU<VMd7{b7;x#oi8t-*{Pw$+&^1^9B95(F z3cp%|RZ}A)@bjusDFz|hC-5_2C<{CDGa{kX28gcy$Yk}UTK%6%Bb<E-r+COi?BsEW z8fT94-u(J<a=icuj2gB4AC2jSOKo>Ga-K;6&xd<o>_OI9#;%eqz(az6{8is&W#DvA zt_V|k`ne4AUz{lFPDyXi7kl6RwSW-=!iB1X*A4&pF+@U)CjiN*VM(QWr9(P%bP^K1 zhGv-MclW5%@bGbn>jVpNIzt3!DF71vrodvMelqZ&*`-tN7Y4HG@+s|x$MI>it&iYb zx>fK2!3}J2Ux>2Aq<jhJOK-dz=&CrMD5o{*jQW%C2hxDXz9z^$rJvAt#LO~$+K=oy ze8CQ+Uu*Bk@jS-2!L0?4KzdRsZn-OT;oclrs^`EV$Z@1bmnQv&eyM+PMu?t0lsJl* zB60yL$1AmXFAp$NCMAfg$KxM_tZ557E*f(0^0;237LOl->GRSHSnS2$#mPIm&=cCl z_wCb9rjL?ruGH$+-9g`QPtcoGAEfRfl-MWv&~qk!vYA}}6$Ug8nYemU9TY)aX!m(A zwL!`{JFlCgi}-WQbyQRMQ$07(9|loyv-t+T*3s+%=2tN{h7W3F*qDjctkBVh+Oy+6 z;8h!_EZKDqzPm=UeD-)^%VyJ|dPqVZ`OZ_5m)GzNG5x||#D5F(=@YlH)*=a9Ve=^q zMqIZMZJx25bw5r};R^I~)Ab_9Y$WirmI&LEpQfdZ#LSW35pPzs->^VZ*S+35(KFSe zKcn=W%#-gkRMR>gN!X@sS;*$-R{je>bbwzjG@XNs%@NQhhy~d*bm2WYGjetGg$wGY z3;KCTPVI|Y^#3l=|A3Mwh}AJA|4(k2U_(CwX?_nQ!#2cR1}3uOpdtB?3C_R0`}dXq z8ng2o{^|bz_6-wk%Fi&baq%hTz6fC7WFTJ({W4RJH^;aYoEM}pxdV`r_zTOb8rJwu znBPdlyzW_FR=2nTDU3&d{0ukkktCKCT%CP+=cX&Bg#A)nJZiqyoaA95C*jBE=*OBh zzH<MRY&PZjq4ugd3tQ?6dWr;U5?VOutb`HVBcbEFR`k3rs1|*%xikxW{0U%p4Zn#G z{=C=p*_-2rsO#S;TB^UjI(l?O=>%B15`%1`OsZ`Yt7JuC)+?MR?)d)PQ{Gw>$4ju> z7@MgtLOsR+2a<nRMN2Dgb+kevhI0oPguN{_hfA4$*NGXmn!(m$AXAPW8?HxdcMu}y z`>BgH9sfNaK)`A<$4vY4@v5w)iI<J)IWTKB#@erFYsCraG1HZ<*WFdK`_2E18HyLw z2$$n?zl@K_$u7?95Vl(Ec}*$C7*M2bl-OP4e5M=gq!=}b&dNTXaQC|&-~EPPr3;-l zug3t3DsrN5Sh$ZJAq?qYkqe;Y1KF6q&j4obO!~6BXH>LPBgu2cV(#rgK~LZo0)b1? zh?_S$W4KDn%ZE&>e2sU}z=tI3HKtDP-sbnrP=z12pnhM!etmuIE|kGnPSw#8Wauz5 zQq7b4`Q}s|;(FBsH)_}<<6VF9qxt~8RSvx_5$ag>m6JVm!}7F^3**>KVim8SaPQY* z<3KMpLFOYnVmP0xRt_{jK5L7yA4u?m(j{(GqS-#c4wQ*e=rzs>adS(1HHn!<Vo;3k zPs7hb(~YFY4C;y?qJO<l?oLh>QcS2(OE<9_B*iUKI^=a}Y2o;J`Uz2mNn#muqlaxa z)}9wQ2KUCC&uX#pKb<ZC9uk|w#Z<mnj;&(gI?zF7XLDh@)|Q%0>o^>5fFZM)4UywL z9MHUcoiudfAUA?xGLeMoa2I8b&f4F45*;uOf;uc#JF{VG0BMwM@FJTFfJ0;sY<J7% zi=;u4;~FO_OY`B(*1`5ZU~+gzpF|2kcX>Ov=VPqLrX{#F_}XF#3Ra4Thk`i`I^T1z z?oU(NZUfxm@dsxvy-prjP4rbMyKJkKp+~A2b#89x97~Yr+e11614++ZJ62gpa;Np5 zcU3+GjZJW^Rl`mUfJX~y!Usb6&6|{_E|&AiLY+a_nE5Kl{K%(XF))kEcyoh6#;6~N zmj;jUXn*Bxx_GT{cL-Lq*+R>t+c9EXvEt9U^aMs?MakX;0EGC5eNqzrcjW<dhvo3} z2KUx-q2095OR7%JR@Hm}3@ctcn;SM+K^ExdQ!qP}@@`Yd*%=INFH%R_OjWE^?X`hT zKTO6)!$=V^G|W}LJL3)fk8yhLwg6og9HCYY(!3<fCvl8&I&0SJ6cUB>QT;LV(e^oG zxhKT1pzSwQr^R6t?mka{>g1ZivY%AJvQ6i?ZGT-iiUSlZDWSVdnYnb&o-r@I9```1 zIG+r!RVKneonhk}7Phk@P88>NQ#DT(sJc!-$BdtyjGqlU=d@2(mE$a1FAvL6hVsUQ zso5s$cE;|W0M6sCmTuN`-7fq5nh?^2?l+bz`hz)DtDKG(L1cF~@V;PCq5@MUC5YQ! zqbsd-3cpitw|LP{2q0(Z(MuI@<p9o#nys9QkK(5i1>FNSiGHef2in>4ECRb<#qw7A z$1Kp%J^;lu;LeWxo*2N90#;JRYPJ1RUpivN7?rgJ$PfDW#nZ*2;&$2$jF!0oekj*| z*?Fi?o`4y8m?RYAxa&h-rIlTU$2QV+x+P8O7cOSzCPXz>tSbf$VD^kMG(RG@P9v!V z{Yp@oGxX#{#LO(eIw+g3NcTv$yjzks;ch=?Ut=OpAv!DmWN07847Gcm_#TS)yq;%o zER%i;sp@<JvORU$V`KIQyoBWK(^;Xq(Po}iV+NvzIfdPT?6_yn4$%5QEx!!vJsjBm zV8C2dGvct-emK0BD%KmPY;`b;u-4-~EO6>IRNWTK@c#<Gj>w&I?20gEZUl~&M`5ZN zcV#H&e3?4?oU-w|?L;eu1j#9ks1-BqsFr>`V*1Uklep1UkFPc}Fjn+N(GRf##No?? zuID9V871*<pDpej?gGf;%G<b7PTZ&DNmA~kdc<#NFPKh~IPdfWSR%*aLLz<j!jBm1 z!C%#C*)!g^sEXpq7(OjT14qtuPDR$(w?l~vJYt63x@-76Gl77~!wB9l`z7wI?i$@d zx?0U(p{_&(<FClK8^;!%gEjrewu_I#$Uv|fcrjvm=uv=bbO;a@DFL+r1ZEtU^(r3Q zSor=UkXcF2xeds9xNP><ckH$Yw0gw|_|O1lN_O?y_reqhK<xH8Wq?f6GeEARNUGT3 zaqUi@jmy*qNVraTts*|3)cn^MQ2t4ON^Veh+%Z)wsu&q-UkxHKNLBlla#T(xmjwId z#cbCA-8S~w?^23z<EqZU`@#`T1OVuI$6#(7uWF6=C@rFT|J%E)zCC~)%kHhJFiwl0 z;I_%ra1uP$??~<0^Wyb>0b~)#SS^MP%f`KN^XTRp1zFnP$+pJ;9}5=B-DWSw+IOFF zTg7^m#rgEuhpw!FVV*xDjAJqqcDrVF*rdW}eas%XOa_I_57&x^Es-jzE#DCHvtcT8 z_!q5)jo46fOZ=5Uz_{-YOnc17#SyF6F9oTwTP-_ErQRT8H*FcUrcc{h?2G69TrLUr zdi7rTaqJpy<<HKnPY=q~I@3+=a*eFM(CgX*%1yU}3<!j~YwkYOKp?agZ#0skQ$*za zNmDQNrocv>0k_V|PzfcIPTN!JxJu&yibNql#wG1vUx|hcPuD89`uz5fa%+sMXYXXT z7U*?9fQf8d^oPjNsZ}4lFZSYD?Dnbp-}j8M8ao*MJgBNt5@lSu5vJzcPh$s?aBzeO zf<(g^Gz#0<kgT>-xYVDPMh^TzgnZTHvk+B3JXNIthRhMBwCp?3Y>YNN-T>RyFO+fd z-I9DJ<BofdTL4Ojdpfn%c^1Kz+lc+mgn1kBSRyt{i2r$~AKR#w3J&jB@m-6~lgXns zxF;a2$sSgW6>D#lEOBOHTN7o2Lgys6><2Ww7I%rJ8yz=(-R<N(GMp-L7y=35ToW$# zt7T1Z#NC9ZC*1L?Ps>q7PJO2Og(V2p3_qdD6n25cTPDN9P8BOfjk39_JO?RJMKr}L zz4M-*S!lCGPrev0^yVXqcNI`_V4ce5C?EW8i)aGK!o}1W1fX-Lt2#DEIjvY*C2jzr z+Y!kEi!HNddzom~-!GSm#kc8AMF-`QHQ_K@WrS+4BoFD0*v`isz9i!?*IH5QAJh|N zWDBtfX2*G`gKkHraAJb7h9yJViyJH5OPx=MsO5?K%lAesB&w9PwUc)xQSNz8L~HKG zpM^A$D%LzF+r4m->!Z%j;9P+&iYXv@(vA|El&uz%?P^S_4s>D&vZq;~V1)fgIbYvN zbav*6vaGlT#bepwJJ}8%i`*UYp|2h=_yHJSom-!m9D{UeF4KX$jp-&z%kAz8F001x z1Ad;aMVp7*vD|UF`p%U*ik`KRXW0kSJB_8;r1?(As=1*8XDp0D*)Brv#*y3#&nEy& z!!}?B7^(q`k^=-FO}t~+B(o{L*yi`QM~J+UK*!zgbugASxvN1??~DwZM66h+rKc-1 zYR)I{w|Ghs?m-4FeTNG~EahjNBV{e~q((*Wr+Qy5!;z|rNhC}<dO^&^a*~pgfbA!W zPwkTH=H@1J4;TRninwyBW*$Ar=>R_7R<o2qN%kyP{$JDinoSu8X<LuJ|5&S<r(h9! z`_1|EU_OuKG7Ag4_0NNO*r<WXcBkMCUU|XMCGDk+PM)J>Qh=3gzVEeg3c%47V9xQ_ zZA)68Jt<58^vT0(bIPx>pZK}cJamwOiSj(!;9W3f&+3%pA#&VGzm`)ymm!mRn@@&; z_!zXLI)Z2TO~<kA7Vpt^uSv~;5K8xOd$Hzt(?>=%P=fE^73JqUbE_3=0Gm6Wjb&-c zF1;dGSYbHG2w$Y{x4dxO(JTF0^0!3RB+!xjs}-v~Q8L0yKyvlN6tJ%rSQPDrwiU}w zyJuduWZ7oPokx~tv7Q3PC<Mc`w^jB#AJ%&x@3n)NJ(G4z*TNI?4C&QU+f6MzeOQ&g z^qWzrE=wCj<`j*tK2^!%)G83%fJPzTGPHeIxdAkZqPE*SC9_`O2d`7*#lvo4$`~Dd zed|>+Txz6hrqKtOJPBNPZH#NVG%@G^v(hD#tRzD4jBpg+2k;$e-KHn4=w9frD52?x z(pLb3u881iey(zU-KkNAUn4BH`fyjtME`j@tOwAi$mm4ZWaqE^8nFO}B_t%^8I@ok z0x&%Aj4vd9@HJlp3c^sUy1Pp@+ekuC9l9x*D(fc{BO#;uK7l3Csvfhz^Mxt<dEV+l zGkS-HyGM3E7%@+={Mvx}X%$!HR-Y=4UmAJ=3y6LAwAasbEOP4eSYoj$OIZ{G`bt(& z%46kNub&P4!Yj2RJ0!TnlJUByE2A5tuVBBU$DBpqFbMBp3oY%I94h*-pg<)Y8XAf? zp!q`T2OU=Bf#R+30*<I${8bi<6^?nUNAy2S1gV&zh2X6!%8vItqB*3po4)<>sfe(= zL?xd1J}@qKtYo$C&~_ogt55N<)2R)3aU@_SHY}SBEziE~W?ZR5L5^Ve^90OAfT_T- zt7OdFC$_2O_Yj2HF0K0sPqq+~n(e_Xnpz<3Zbdf(w9RCW+&TIQAs1VJsS2&LHYX~1 zujzd6U63xp{9`t)>bfPR;jMWaU+I9JJbzRdPEdENpi;I=`8Ncgv#IF4?l-B`z^Xdk z-0I9IA=bgu5dY*OSHY2sXx4sPw57z>*Hy%-J8X~yvYCp3;hwprVGy|@j7Xt>2TS|V zCyI>dozm|uw*(nvIt>xs@8u=q>4_~9wr4dSYWOFrNVX1JwhwwL&UXk}ZwnkR$RF^X z92f44#(#*dwzeM9rzS!j0%%U|Bc9Iag{YH*)VM5C8uK~oTSuB-_XJ4icH>t!2Yl%4 z0B7C`-8*PDGhh1GmX=?0;mPIKc3;IG_}d)JQ##ATBr?sW0PLYg8oBj_I<AjRVQL;( zn{oO|tY)VZWFe{A%41(WykG;2aAva1oY?9uLhP&!B$y&lN@bpfYriQHWl}3Ghl|Ve zcUXrB3GX;@Tcm6SLGyD8)n%d%6H{ZYB85DpS>-A+?7#Z>tc)+kqH=0fY1iQkW<yJ& zPa;=K&Q8(V*9G9mux#g=D5-6nR*Z+9vAi-mks|eFan5bBL#6ks?-BJeK2xo<soy~& z@0q2L`bi^WSrXJ`KfRx4o}hI|#AB0w=wD);G^BT|waboIsGPg%!z!+8A+0&=Q!;PR zX9$oiC4eht$E0|~fYzx0Ft3Ovi^B^jDMFL)RviH04A{w#ny73kH=%mEMEw>0;jW?H zJUNWklmEyH{cUJZvq6FvyeK3%&Y0NAktz|#8`ItrUS+K$y;nWc0~|BG@$QxqDWrHX zuCv2d`s^d!rn6lMAw`60A`28M#;((F#ZTq2?NU~QpOBvseZP=kO%MCxS3O>OTKe2^ z=A(<_H-zR${fKDgmTEv=B_cSp`^nn@J-t%ds3rib+yh1=h~N+y2;Sy%2DUDJ>Lh6u z%O#Z)IoReqi(unuAwjJE>g-$o#d8S{TE;TuxaPkik7jOIC7P{QA3^b^eK}-r@!FbE zsvx%pYWyR1pb-`%?6?SLSB}JawkuIlG1Q7$s+#uQY?8N0KVtiEfDCw+)pT%Dh;x&w z&E#8Qe@oUuIEMs#)@ju^rTx)+-A@rg-tcRlQ@-qEj%_b8D6RvmwK`oY_~{%muI2|o z*9i^q+xp=Qn2~_MR+X3D9t7kzu!3P1ETf?!kZ>5sI-rhCg&4{V_)J-ZNWIe^Rjq&J zwLgPS?otm6((_it#VLRor3e^(%xijfLSM7}?dv3fq||uo5OlWO_(jRxQ=8f{^!1|q z?ueA@l1X7^5RmPvu=i5hm(5evWkML8{ZMzZm}`oqELQ=&WD`}oR|b8b1m98H!7$)t zkx1BBVo_W8byu}?vXPW!xe7&Bkrrl>RClWP%_O~NOfRW?H|tU}?UN^Ld_Mfg>AsfY z)VPs;5|uB4d|9CzyUMUW!_4er&b1MXwOB@NcfcDs+#PJ}D^$OR{BamvVOmefemH^) z#ha(KTN>8;EM>_2ZE&}x0Ep$)T&y4%wfJ#YrQ7HVNN_%To4Ii)5-fdaUuX6bkE-6! z6D~MtulG8;O!28m>kzC+XgXZezk6XOpGrs%uSt0`@-{0}8_$^eo1!OYSbolBt)lKL zLW!@x0lA1eBV`iesw2^jJ?LqrkK@x3o!YW{tj9bC_$`~Cp-{QVfx+Q!h1-6;dJW5& zz<UFw?DnpArvKjX%sqTrs9uj@elAGyxc&7iUag-4Cm-y_@5g|Z6mNXjT+|pH0k_** z(k9<{M7p<}&KR$8<8lsoQ$0gK{xTWQ_XjUn__3TnP-e;$NTtBl%Egyvb4_qw4g$}{ z%B{DK?>U5NS!yF}7=O1A(K6GN4eCTWlEE01$Q6Eh0v?ad+-k+=iZR-?t03W^#D05W zL%gmeF=z*mZK(NlzLC^uJN2Mj&ooDDxLBpa(jHJ8F@NAvuZ6&)l6Q>qKGuAS#vfvk zFjsGPuGZh2`RRVSMt0nSi&nnadv0teG2}->^rbB*OrpLh*rZ&<@-$e2^QoEI?%uc) z*OWJvG;2w99Xtjmtt2>Feo`pnUQn@I;G+_g*;E`RzZrFsdl)}l)cwS~YMyJ5x%LQn zG!&}({mHqC^}6d$Won#G2(iVvHyViiNvt=4^4Fgpw=5Yg9(Fq7)QxYC;gXa2Wa*IY zKY`PI&`<N+AhnA%vARIx2EO$|*W_J`;+Ghars`*vE3wV3@j>Bz!XuDJGAE^5HeE9A zEaxnzxm2jxek=j+&O?6VEd}nJ!Am9KQ!8altsrgu(C%(Vi5?8?N;(tWlaas83>_-p zE{ij3Br#zAo^rd!!zN2C6DXn#<f0IM;l3>O5oB}-Mi1-|RVl*;edeChdByMRTPV&_ z<|_K^>+$7XlV0RHM)fR@Y5llS&qgi4?f|i$3Ff_XRGbQ+>&d>Vu_+hQIf9;>f?r-@ zU+v^RTDH|}RE}v#R*rg|7&I~bvl$^Is5uUJP;IxKms}@jeP#v`@6xM0Y@yX`^z9C< z9JLxAWIBk->)s#DO0V2ot-y<Jo-<?~;>v*H>C%V7f*GiP8axCD<PR21cH0ZFJoXtz zoDF6k0lKlab9n$)ui14EeoZ%kB0FUg><Zc1P7b;R@%!nDtdKc!s+KZl=254Wwp+jn zW!hBj$Wj}+-<LS_9C%|Bt~L3Oc5{D!o!s$`u8MgBmw}mq&tZQZc+?c8!xW|MYd6E< zoIyH}-wp1Cdwzp)VEYl1sveRW=VaHA-vp#twSiE`onjXJI|Tr^GMlm0S1suEUL<;6 zWPH$$Cc8>XoFmzOZ?*FgcdnpT3t&)0Z2*>|fM^$u+$yy7K6*AB&0+9Fso3xU?ywUw z7H@Z3Nb%QE{LXWy_RqK(5euXnksH%HPy3Ffk7_z2EV|Yw51+t!gSjVOk<Q%rBKslt zDTl|g;cC&+>&4=7sdr19$$>a(@7}$ot6FAs95t}u+(-&0e6zec4R{&G6aV@G0Cn7i z>t%zG>W{IsJ!weHysqMu0ZEkZ)u-8O)76eg8RPnWK6NW-&&V2e)l?~MPTu7jnSFBS zc9sA@=t>Ris4S>F9fHwF8{|z)f0iPd1?F-eofX(eS}%^+HV586`Qa7)R8#Ns(eX99 zE*crNrKxvx@7sIrvccXnJ%<Nwk7n3X(2Ysn)d0k9!%Z*TQAiF8t1<1nJnozY8KGfF z8M_{o)8TLGA#tk#GvO713Vh-T?kUcGA<bq0p9XF|%_e`DRGJOlTPB_Tl*ai7bF$-y z6(-f)O+mY@fsOY1cnPt<A04J*6%l&uhg6HRS%C{xr7a>@Q&M|a!3ihB7L3ktv}?7f zSA0l18U}PRdL5*X>Lr<V!u1OImVqI?l$t>MT+mH81(n?YDy@6q7x$%<v7`JZa^17H zXWWZP!E{yIJ>l~vt;&fnOiqu{P86{7?EgImyfsbi9qcjka~REk_!U`RuD2}jh&|N1 zMxb}cPsq*0>zN@l$Xz!@^+_IPe4y4c`*iBS4Kzj7Mkn8qVYF?d-x}cTEHA_*^=qQE z&yq?Nr{T?%ORsLv!^Y<x;8y04=@7zRd<`+?4pTAzy(E)K0XP*ZQbi*G0kW0#LBeIq zN>BkIpQ`PSj5Y@zL<w&SxGuBlnN~VYDmOeSl;^u|g^D26&^tzSKUj}DJM(Xo`EH;c zynv3>8`>~9?yxoWj{d3F=i3xlc(hvxpZ8dT2Y^G(8ThIZ`*9kvf$Koq@A&PEfz;nu zJa=Mt5+;RE4jYI;mrv8}3+V@|r6;?<5hSb959rwzKn{osA7`<F6rMohD9B?_p4u{h z9lEqyu{Ul#4zR6je4iG;DmL+>bY|A{RLueOsRo}u0YBSXF~u>MWWKyd%PQ7`vv8!E zT=E;&UZ|NgAAi{|PFAJyJ9c;V*IFCF`cX;F4X$WwWzZ9&aZ!^(D#TBhIgGH+=Ih4U z1AvoB<)kO6n+Tb40{u%r<w`!INQ;$pO|#9XsSXD_NJUHZu8YM|B@`GvqH*899rt!} zC+4y_wscH&g8kQ!eXv;d$sFnY{q&9EDjQ1Wk57$up@yfxb5CKjF2J)70s!<GnZEJD z1}A#1GV18e{4tv?F;s@ikY|6)A#ic;kRMN7qi^%^f3en=8&}=Bf*M>9rdr)COrMCC z;mpo~MZD41UmQ#nGAbC<0fYy|c4-}gDe!Obm?t{p)@LWJVe}=LLVjIn(}b>Ue=3Al zj(1}wI84RqfXTmgSZ~FV$-cP!aVO8&ahRIpj$={lxA^WZNR6F1poZ@0aQdwsHVD6` zwCUFf08%ywL~@lNTO6b#aLh+rWC<MF469c1>;Zj<8p@8cT#Cun?UYRnIv)J^jc_X& z9v~6UAW$EKFe?VWlo-z04x?wbODw8(zaK%}8~|-7eC+X~%A4LRgMie-?%0M!Hipx> z7<ukD8n|89?OnE_fE4KoC{J4IMKz|wzoxzLt2o}wf#qi3?YHh~BsGcN|G5bKz&o*e zs^-gIX>%)i?ShC0t!qxU^9?9VNb%<5O@&DPeCW&Hq$^qTuGeDswr}-QQzfDouwZ98 z3v0Ww%}I#y@ZE{@!tQVuSg74Iz$yo~xGEtEC-u8HtV+NNeZjY|Y{-1<bhd+3%iY-p zzH<Hj9fXpx$nnE3q<H;XY{Q0ydkAhZJ1JnaH*rjUCg8MQ&03pMDAYwgmwOyNl|$Fn znp^o}Ek4noSMlhyiAmRBxwHH*9^*X@c)PU94Qy(!)5uPOb-T=$33yze<UdlB66I|D zCJ(q0CHy#TtJ(^Mc-Lb-BKHjBb0cPc2d^BM45}YqrhKB!ksIaK*aukCt;Zbq0B7ZK zLiO{+YWzM?Y75pkNj}wcx*)2=ktmE*Xwxq>gfZLgt!C#q08UR8Y2+I|#+mrZdSO2( z9EQDwyYwS{JVIvt6-RD26i-H$==mYw!u8W+i@fP46r9-Ie7gm9mL)&QB0;UyspLQ6 zO<_b~!dPh806#qr`{e^0(SOKu2^^7;pkHJ-Sm5Y&LqG1jk4}5!)2h6jck_=Lzubfl zigT?bDJoDUsX_7j5a2{cPu(bK3CgcH2EZoJP6J7{S`vq^S>wC;#XnyCv|GNs*&4vT z*?fF_3{<&4CEqFVQWWL>xX3mP{Qf258kZeo$Ub!m0AE}0w@2@;Gha4RELpAk{TL7` zI<eTvYYuhm-Fy5fO|{An95%NCpO00G)NK+d?7HID*q1H^_^9RLI*}zN<K6So!TX%` zrub028CIIwK@^AqLhWj?&<u+!U>xIY=OXa9qsYwb0e+5k_lHkLW+sT<I0ElHvEE}k zN;c!ps@=ogVUsoHfW3~3P`rksHK!xslSi{gqc^KS8X3k%=mD?d&pMC94&I}bC}YKZ z^VuT(-p75G>#WLdLfB<~nl4f8=fU<7PfZunj<W0jiVXkTK=^3@eRAt}GchJ~cv<X0 z2*VUr8T(Bas&C^D2CUF!T3;Sce;GH!(GQ#EtPOer$!#K-F^E9_VlF;V`8ji`Hi03+ z>uR3He&tvoCgjgStA#^ZzcW&O^>~io`@Bf(XVco@w5tl#CA%2~pTowqCvZAzdD?Hx zikb~);*59M)6d!F_9+3nYA9WM85PuRB*ImN=Mra3Q2&VU%lrP1F_CfBGa=t?F%>J@ z(GE+j0gjHz4Vx)Edz%k~b<19|9xwYaf%YViCST&+6~a>n@o3!K1KPWHT+<<`QkItR zFsV#*lipm8Ln_Ir7$p%Asoxc8z0%~YB$}-e@>QMT96b<t!zs6fkvPlNgxNFcQA>ID z6;Z`^qybG*sxn0D2bDQ|CG4OU7n~1S>qib>i77T;AKr8!Z5X?q-IXb3VJ0Gia=UPj znc>fWIa&9%W~@loakT9h7JKxmK~0mx5QrLhxSnGqc0LeZdG`GI&gXg(kxC7uD$aly z7P}v%d0V{;RU%`o_vX@4PfJU&hew+SrU!0&TzUQFTT9DV0Fonlf$%0Gd2*?O2JZ;3 z;a1t354Q!WD4OnUtsP&i?`q^U`Stb2b0yi!?)?dp420%u#2EZaxlNk|@6pcm)=A+< zkEXV&JM)^~>}ZxuH*UF*iblyXQ!8l<Xx<q9d|k6UL*|l`v(r7vz_7a<9CxU_(JhdC z2S83&acJFt7Gz`q4mIcA+=EP(f@i;re_pBGDH57Qnkgw6$Pa5jnUTWTwV?rEsL$;m z%x7NE#TsF73ph+DyXAwV?(&Exl;zy0O-aygkcySXp)@pPSf3DxJeGNQ$mwZt>PRet zo++kGC{D%>F0wP<Ag8BiDcD%-K@DD5T-Rz|FqzJTt}1Pj{J5Nbc62tQ4sNdH;l_2= zj@fBRQ&|%`e4~B?0>DMlyl_U-fqfHR_hhRP?JYEdWh86&eRZD`lNV&<UMw`C*ou6B z<IJ`_)8_pVniOv&UFV0Ng<0azV#(m^NyB$M7biITY+ocbkPPH$=H<^7M<u*dM31oE zou#+MJ8x>*oO1vRAKp@1ZDS-UtepXk7pZl;O^H7;0;?JP{T!?Ge#joDPpwefb;D!( z3|)WBYXW_tx^3CE&Qmo;{C>})^8>#b4SHQQ9kMadRYBd{Wq*NLDbF^3<aW2Abjh$s zo;{Mf$Qtc+lylIun;RYW>cMSvBUYu4+Bz-yo^24Aduy9tsa?Fwg>!aPv5#E48jN0o zE@UAe`5$%YT)|>j5V_-eX78{qRN!16j)vDw81ML898^ASFV}RbqL7H~)Mb7OBdP1I z*ULV8+~Rlj$DwaF`%<OR3FD!fL>4tJSKPzb={gh6XEbw}=y&tGQ+)(1?BghvY4&!O zyN$LlvxueO*~hb$<UVYN+#Oj+>F5X~<y9|QcY#C^eSHIZ!BK@5;&<?<ZFSkn-tZhC z>9NoD=7kKdw4rO;>IoOgb%KvCNj~{aAlAgkS>}&~J7>4agoxP&171m(I*#}X9D~O3 zb|p~jcSrIs4fKI0!5QImw?#^CnEL0jmNKLoqi1TDccdJPKMFEW_z}%~=+gM}Ri9_; zSIEf7G!>?6`R8sr_C5kpSYwCqT_KU%n1@Vf#)>qremd+Q5dB?`uKAn5R6Fwp)5d;H zs>e*|Z*C{oXT5gIrSMh-8utcCBF8$HXt$IvV=gAH4qAPp{!>Vz@E@+F&)2+hpwax^ zqjLlmqnqY=;VS>7(&A+(Mg&5VE)h)(6|4+NqF><RIZZr4e9kM%zdHE`LB(}WVG6KV z;MPBIDD#{D;aaj-#swE3NI?a@@ffrrKrk6*LUaWw=FjVB78sPp>hR?5U^YbG)-0d^ z5KDAeQt=qM9r5#Ap+MyRg0^V21i-mb7nN~v(YFVZ{6VLgUh+RjMEwH`Q+ECHQ8|Iy zO%QwZL<7vME`MQm;%*q`$!3NB0O%^cfnle-xAo_umZ)Hbvlcxf5J*Iz5s^C3J?jVG z9EzHv`BaW-@QNB4^*+}s3u_!%RldM(I_VI8C?A_Qom;1{D}sK8pK9H<>P+W8lacKd zHI+m*sppAT{#kA1_(rs9{Tp7B$9L%c5D_9Vw!L6-X1e_|Im<Z38E&8_=dxHYHKrfh zUo#LE1ZSL#hks#pV$4a5)H~zULBO;+wAIgPJ?}C7MWbDfzJw<H1)x6#S*%^?YAtRq z`p<hQ*Bc+v)S@r{LudYL{M6AuxX}J@3OXnTS?*w3L4p{#?~Q`!2AHz9?O@bg>F1{F z0z#TC1y}QYu=AP(F?d6l%vUcZH9bGj)QTnVdXaS$;G<_OgfYYFN{b;h<w7)1*3+&w zH*~)SZe>mM4#@D+)G8!%5^m}YhGVdONil8`41|nO5~7J`ADxJUu4Co~f5&FzPiphb zAcChv6ORd}6L)RuCQTX05T;i=)X{I?5PH#+I%LBB!|Kpd>5BP|B`Y$F=dUlo<xE>= zLh{M0i<tM?XSn@rJ`1u|X1~O_tGoI|&kc72T}7bC&wZvi6=LsmHe7!3#9h+a#$G<# zsnOqPj&uNdYxu&kdYx|&XsVMo^@nI0VMxyG5dU{T@BFk1gQ8@7#&y9oRD~3?Gz0C= zR}COR#gGF-z^s~nC_gBPm2wp`DLLBXi2CJ1F+Cipp_-8^wEGOxVfQruJZT<AWpbEa zjj7u_l*;78`Ba~w`#YY0m)52KPC*dTckG@6`VFSoVI7sp7nWzzEio@N$9_S5^rs3f zx55j{EWR$Cduuh{eOJblKUHWvQbsQTJJ<-x7D@daq_O4<xVuH@zMe3kY#-38!AQ`3 z5wEbt@zI|eI{Pilt<f?3;8n+`J8Sqn`30&x{q&iU3Y{I5r-2&Mm(GcDuX-4aC|LU& z$wRET{SNM`n&crE(Y5dw&iQi063(AJzEUe>?o7;tVqr{H<J!_``Xdmu{=z?%@1r^{ zU^<fj@qe1VS?|xg>YiNoKUW+ywff}`U!dOt8T9)r5B?I<Ua7_WAUE+)9)02B+g$9k z`lrKzm{Ug=@(bDcF26hn=4xI1BdYW+{DpxsoG-w@TYqJfhanfvK%CJv%-lw|(*G(G zA-W<+S!2X@<sd6{#cvyVjMNQ;MT-PH?RtMMrSHvy4`40emwhF1J}iN~>^SUS&*#xh zQ5N`v)*$!qy;`F$Y8BqwC!fn%4r&wt(_j7bW2)J8Q^NDsH|s0B$C^8rzWgcLuP=rF z5tI>)FKYk3_<z3NjC>;NoPZh_-3XP-?0@8hOLV=Zi)O-~7lI8y;vWqm4z$ISKTWm( zt-SNE>vQ$=pD?q4@Q>Tc<OlKci+NG%vvE7`vg?+7GSMJ*3qO+$DC#uR9``>j+i?c` zzPjk1;}_rH5Z^=Pn;<)cS&X9&=vKeDp*TJAPEed%Ix(GK;~Oaw(>j6gQQw~s*qv0w z3=d1Vk5%e_IrqRzV=L}$jX;MxS_9lGVb|<%&Sz2ORb(Py;f6G|+4;5ow{|WVy#R38 zfAsC&(f@B_eE;uJ!vDY4u+PUE(1h-J7qXXaW)C1|t<xKyG3&1R=~oWk+_0O58>Bg@ zVn0zE7wc!#(07&zaZr=ppxylJ%`NSMXTknZ3th_JaW&#+lPOP0?gdKh`{)v7{-0i8 zZ$|FIgK+qR>5dL@RE~8SC3oSV#Aq`%GutcX_UCZ+5qF?%%oeH8kLKL^!Uv8bJCecL zW7`?5{YWRcqNlhOJ4Bt^!vkkBI<`<gU-uI<6kDRT8_p}~8xvQUbo)>8<oC3c#<soc zCzm7%)NtOsld>mS%4u&DEuJLoSX};NozM}821X!`L#R7WEJK4<)c(v4Wn7GC{}-<Q z|9;T-{hr-qMSl^b?D22`#raZoq5WHNAdyu3Id=aZQcDRj{b%_KnO?`3ED*7O44eNX z$ewj!s+DX0dM@jlc?M;dwa(3kTB5%;57nQG|4`E(vD)ly*lPDT3E;{fGb<kVZ_EBp zLbteS%-M_(<3J+npBP<rk0AQBS_(f#w<!PYWWgk}K*|8*pW_@lA6OWEF1k(0|JP<W z`?ucn;JA%ZDdys#+EvSG5PSB!n1e!RqMx<e{jp;s1TL}T{E;s91=$y}er?#IDBXEr zsxs*}-k$9qS&#TvR#si_KB|XP#?AONlvBfJ(Sq28Y*vNoyB*n=bRk7QQdL-k;)zB3 z^4ym(RVtIl-|nyx|J<<%;KOj@0k#(-)Q_oYsHAF)CG=+{pYqWLZfuKfNzgO;5R5RQ zBY0b08g8gs5Zkp;6uV%gmX#&fwDQS<4*O4z0;9X8i)Q?7^xhXz=2iHY&L3h9@{Inm zsuM1dVgC`)JksFybFo@R-CCe(buL{XjK2R)U;SIA^}h=3zq&ykG^-ZxScvtC*SRM3 z|6Pz3Moy%DzTzP>;s2(?(;$BiCiriC3D{R&|D%ugfD-&;56#48p*I8%Qvdnh&6Kbu zT69<4)8$|^vHCO8%@@yy86x-JH+MW({ya02@y`{_pQ4ic=d$Ks<n%S*+)ENXbxH}c z8%Q&*)5R1`I>9z!lsGt1StuGK`yd}TA3HuBDGk`N;fAFY+Bk<Bj*0ThL4`8p7|rS2 z`N(cuCN+QbaDM!;1=pSDVk_Ed_7PLuMw=#$tt=SlRH^8bMTq=brPi57Th?7cPIGPU ze{A?#qQ7G`!C8y`e6;bw#Qeu_`In~o<5kmoRjgv|^=D20`*rzm71YF1>pkt^r)3zc zHW8dAP&v4M8f8dQH)&WwhjGVgy?J>0S>>*4j7j*p$x-StOk3;e<1q>jjs@+jZ3j<{ zkh`>VK>ji3m#6FYQKAmUeAhlPknP%eV?M+@+D)P>WL9n7eva{tkmZvGOcqpNJ)x~z zLg1v`s~Yb>h07^7uY)@8H9eWzZyTbg=4ZT88=4p4ihEvz1Um6VP7Nm=$6DO`GPp6Y z{$&H%<-b$nznb0P=Kta(g1>$K|3M94KIw1XTz-FZ&&FVcgmvkua<1BI)#`sNT(br; ziLB<}dzWmd_|k*+Uz6t!Y}@wcUq3ZvC%cN|GEDL6F*%<?&#W>`8twG6gZ_9pf04jH z>U49qeBPNdtNhqIFV@7kk5^(ag^$@qK*Px0l)3go{%(N(is*kk75)!ym^j<#ra0d< zTzS#-1wxkaybiTvSEq4a=KaxMO{0nbA{KkW)d%hm5^fHk%Upsl90`FB@CQ5ZL3}z< zEOi=~BQ9K`330h5O&{);#tzH5j!)xCo%^PG32g}-j&t}Z29mJ`I0ulhv3r;!k(+HP zm`>$!yWqNf=9-YxwfbEf_6;KRIEQ&R#Od7{h!JQuDJ6zutdN|k&6_jjSMw~Q1uv;H zV$@~Wdiee3)6{D|wdb;>_IVnl;!4vIT5Gwq8{PUsD}d?9e~E^>m(}T9(4SE2p-kqX zOz85v0{uEk_e{6je>MO){|CLOj;`ciT7DuFLUwKhyeOrn`@8Vn#?f~{?qL^X7mNO{ z8GV1%@J$Y!0n`SuSbOl8_7!Bb!K%Zq>GmUxG&d#Ta<Kj8pq!;f^5v}0w%)<CH-(;S zOXz~>laU7D$1wdmcxb|^_SeFJ$%ku(OV*f&{OE62xA8BCzKWF(V1m*`?nE*;J~bJN z2z&a+ocOov<yh#>grPLL#V(?)V6TFt!WaWu(7!g#ADiW0;_jcSg4NHv{XVU@TmSb; zRjM|TQglT#PjVBN7R~H8x>vB+iTyESc<=5da$DgI<oxROwtt*N=W-Jww@$!|smXZa z@K2rQ@19<u{Obz{D_m&R4n)@s;b@Iu`^Oq7$T`<R{3*9wFwg(LFsA?av*LeS!}{6s zC`@kZZ4}PL_lbipt2J=G``H;%2N8L&E>783%%dACjY~>IM0o215<XIOOYp<z+LoBq z42TO2M&d~)ghHaxrgcghH630>Y9#N$L3I_L(a}UnWybC#3Mp<p`%~8ABiPV$E*?B# zKCNe$;SrNp`DXWAG<lG<ge=(BV!-NYff29VTZ!RyyQmbFikN-tr8re5c3|VzSWcoe zp2W0OwKuFVvuLyjC-oF%l^7BjF`zBKzikje@1f(bVr)MdVpbxiYj?n!3TY9P>?fKi zHgY7o4gUO6bafnSJDui6?Nf|XMrQG4m#*`RHR_Bf5I)Bs+8VSj>YLNQl0k!qx}btY zf%)5)evwioKGVv=y5zkXT3f{5zU5%Nwi;R9Pwrx|y~1b;#8j6ODaZb&lBJxpexj3< z%D$&?Bm3cEGmcmHK@%4uygA&{PKskv7fXt7ChgkBxtbW8kb<_4l~q^8L^{zj8`1=) zIPF-_oh_l0A-CIBws_)buQ&8xQU`aHr=9rs4O}`LygRM|+PB@t^dhxmU22h7UF0q} z?@AW#+KllqNdHfLfLYCHZvUgLE01Pt`~JZfeXS0jma6HMmZG8$O=v^<UXhmKRVS?> z)KpW56fw6?6;)9+J+o5PnnI~ig4QgCk`gkQV%8WFQ;_fFt+&?i{nj^s<=(aKK6`)m zKIiPuzUQ8^H>qDaahDnRgjNYWob7z~{QSP{e+cf;&HoACzaHIB7Yp|L)gR$@<jTD+ z&1RHd|IjUxZV0sku0XD@QmG$6Jj^j|APSyjieeNs@%#Pgw%uS6C}VG;*>djxIBazL z$%)B-F1$|sT)meHT;UZx7}ok9u57G`L=Ba{bVnhBs_YtoW^^^}2LhdQ(us$oQ~oob zp&AG5S^x-cDO!IM_w8>45dSv{{xO68R*RHi4+wC(vE;IEj>>+%_HP8<MDPu^p#c5; z<{V4+t2F;a(?4?dk^Bb^KXVNqtv$!foBb^;y#H_m_z$3!qKrE@Z<RfTZ`)-El-m3N z{ux(x|F%Eg{!hGToIxRt+3rXY09$sS)K^m^Ic`5ox??d5DpCYe{Zhlbu%^G=7H-!V zxd+FUo=Bbefm;3_pMbf<BK2ad?dLsu^hfxxkZqnFU<Y&t{n!7qWAW3T_kt^T`3CjB z?Wt9|JLc9euoUQ9>Ht`GAvpw5YIjvp4Uof5``d&6im4d`P)G<^Lm?0V-idAgWUhIA zalcISNBG494Fy$zNL7^DpJ{(9KVuAnhyXD*DiZ*l3ME1i3UivJI_YVujZ~_`YCWbI ziohI5l|psi=G;&wYJz&C;^1e^k9saFq$U!cfarj<+ta?44{Tj&XN5=2Zjw&g%De$u zepjFbE=GVdXE_)4#hbXE<3XIBhD@w2fH{s>C;&ovB!n=30XQ~QWsYChxP<t*ItBew z8)!}?`jHslbbs?}@VEXKO#A(?o>%8zniS!yQgIVo8`crQ8cGld0@m4lpeG!OUMD`j zDulq3v;vie^4n|bc>H3JJrK2}yYf{1@Wn~Tf;k?2hJWXpv`lCT-VX}h>(-w>5vn)2 zb4LD)al?Y6l!I)%&9HWLE&W>iZ3{q$RI0Ryxl#VTd+O?ttJ70;i4hd#DPrhIPt|jt zuC6Y97>Gj9z_;CjyU$YZCs{pcdmtKIgsJ1I7|^tM9A@kCaOR~w6$~LzNF59Ktap9! z(99OSb@nManZa->?qvU7H%KOCc$}3|-kBa7v!-R%4`2Y9qhy0IXdWA`q*OkULCLS5 zqipwtgSKDfvRK68;scEYKR@VZ{zaTL5Kw%|JvPVTVKFgn%JujXQ?R;oa1p3h+{q*> zn$!@M;QPFhG3KQiA{6#Cq!+O1V5ACz!(wr|y0SK^V@4L7f<}Mu^Hcz-x4w>U=qakp z@XJ*%F24VYaw;1O#9(|9L~Mmd(J1Dd4K6My-<EoAlGtI)$<#V2OivJzMzp7CEOBY8 zW&(=3E}fXx_qHOyJkS+4Ag;Sf=H|>fza80~^8|&siDx(;aw%gzC4UW@Awyd<50|U~ z(yy@euqDiJyl!4B+<%d|-P8Qwh;s&~AG_>m+2$ClEIcv}Zu!At$;im)KClf8<Zf(K z`E!N(*jYbu$40a1vLZqe5fRAxkl>15;51gxkvfl{7Oxowk@(1bHSQr{u~u8l)?0!P zR4VJFEoQSMG?JyAskOK>OQ81F8{}DM14|}%J`+EkfG#X7lz5`LdwSHJG9kr(nG1sv zotIxUx{QyUO@VbN=^c{yc=u(LGV2n+o-NIeeHj)O79Aa(mX@X?ySglQ2k-gtCl3(F zR#)v&lNTX1Y-gvqxcJ;<Ya}u&J^h9*k1l<rK4{b1$H&Jz;F9A-6S4l>_c_f*4^y%L z-5<k;PTb=>Ynfb%YU1v+#*)j+Bkfv|bs`ZM<ng~kB%MTOXUKum$?NS57K>#i>0_wu zfkFW}<F`XDZ)W{G{h)ukHBqmr#%aq>>RDe%+T7x}aknRWxLoe#%a`K<dBBmEtwy+5 zoC<!d$W0JCF!m82^B+_Hxg}Cz;fk9Ta%gB+k7VZ=>Zy*)Pf#ap>%n8CEx-*;%an;j zYUuYFz>l{nRxc|f+p^4_pK!Qvvgu*myu9Y_=BiRh+A>9ZYJ4yT$=rx;t_$VqojhW{ z!)yj49H&_s8H10Sy00;p^U(^?m6Fy6FKG{UgwH?PTpXz<K%mUb61&OvcgGW1k!(ut zbpNN*!*EN>-dYQfiUM~u#?8&oSxF-l){2!sYK}6B5EgDcpZYi;;1mf)4Q%Y{!eU)K zj`y@49&TUkk4YHc9TbFaK3#TT?vB|WYhEjypNCIRqpMG^8O~101Zu1;wb&k`snncJ z#a^VUS<+~ZR|!L8%CEleMUYHu806qenK6@s<uj%|lwkB6$TA;dq07_G?`{XdZUvo} zwv5V<GQ)g)D_3*QrtXpNy0jcCr}v;eoDLs?hobbxjezk^^+!gWhWwQ?kv#4$F})K3 z+KdI3Epz7YrHA9p)D>czBtz0M!@4)1q6b5^H_D{SCR0`{i_VxnC#3|X_wjZp$eY(g zToiL{)<aP$cu=fv@Q(a*gVHFH8anUHpF<QTGx7>rq0jcdHouKD^lUgphmm+y^LOhr z6_Hiw(xh%7$ER4^XBAcD>>C^2kB>(;e>t^zL7dhR-K-H&ZS18#6dB4)G^^AM$vKM- zYQ2-L?W?e5cp1OKN4!|@u0?<unR%l=O!^`78*h2lxrhhJue+93R(<U&+L<n9qGBI# z%YVJXqxNb}T-?_9EX<_7>^myt@=iy_uj50BT;^fvb%ZQV+_3GXu|S-0nl!b>R01B_ zlC|I+8AB_3jTJB3Eu>Q|w10MiU3hQ@3{7b9V|B_v0KetAP%R>{v{vI|q^9A)o^gBm zyRv2&Kdo%&faGNz@ZqK%<yel|;?lJ#2BLB3J`83vfW1f(>HxoxmR&a+GT0P2dl%~) zK-C?ZpC76RhOH-`k`zORl}*viA1k{KdU@wE5?|a*H%N|ON^ny{r*2i0yXs7a*iAMr z8>POpylx6MX$CT6ymjNbP@Dj`@1C@|qlnjc0fh~NnHR?A<8sHdqd@+;1r87$2g6sY z*OhT?SX=$7*InH?!-JL9e3~P-P96KA>`gHMN3_`6mzgKk)>h4kldh}x-Tok7xw)I> zlGiOSOPfNqmw6GQDq|{R8hwjGHFhAmrqtd7R9A9x-We&+Vl1*5k9LM7L*S(;h-!JC zWaAE|pM4P|Bu1#oRegnDGz#PJL&%s={UMnw^uX`3W?sqJ>Ntu&BJo6v5x4^t#m(}M zNbc}GFIN15lhA>oOD=z^P%Fu62EVwcxn}ue6Pq$_pe(0lowh2@94l+UX5bfcyqEML z3z<Kf_co*X<{5pEc(lSJ`M`<}W?Q^BTYtQS>_)@6!O`4mokv-!Jk&(|(z1?gho&4+ zE6zg@LoZrBW8v}}@>E6gr_C2_cx1iNX}?!BwYcOc+RX@e3~$i<M@(q$lj>wHIVo3V zWo~!P7q)~9<uP;>*Hx=doiJ7Sh4B)Fkd1C!So~;lcP@)DvlbO&h?Sb6&MJ6xm#vfZ z8^`UG4LYNZR$TNP6utw4k%`_JhzuH5KwkE|$JcEc<)qIf@V4A)2M=L74zCVwdDlGV zS64d^l^4jr*~Ic?=s%8i{Cv=>4tGp&aQ-GN^K%qdCDf!M-wHj5-FN^aSfUWzGrp#T z2t$uZE@}1D1Yrwa=t|ol8`|8V+zPN7N70s71AHV5+;M_+vU05v342%>8y^Tyo^;Uj zbi=sKulHnGr(T=Q!05%+TvCTr;C+lr?8Rp!5c~+QS=ZWXl@0TpZntxL8+&bSZGn*m zLjW}<naAPf<@8v0;Ieg=17guTC!5=%ob=qMJ6Fp;>A18S5}?4cL4Cu(z8SOT#qx5v zl+;ZJ+)3%?PPqS!_XJ_&XzmI|o@_W;KVqfdA+6|)LJ~$DWwFv12X}}>T1EyrK3>_f zo$|}OV%z78m8D&ObzDUc9cLlMS#y^2@$>Wc{#gRyYu_p>mzAD544YLw0%|yQ-}f^~ z@^r`Th-%!4@RV~CFAE>W^J(i!g0>4Fe*9{hvk8HpIq-YsADF7912u=(((1jd>dpy# zc<)e#u%BTHIwvBS4!+X`UPd*?Ro{25M89oOw@zkC2co>uAK6;iHCGd(ymP6UY+Y<c z@0W^8&hfF-WqXAHAB0z}n4t@dN>0p>v-zP6w_YDzU5&)V4VZ&z-5QgeDpE76ZN~p? zgxUNq<xx+r^?FZWk%xj1!ie4-UmYvw&ttC&WGy5iRD)0it)p<6K7B69Gf#iJr@(x! zFNfX;0tp6P*Z=Ld(?o|^KCP+AwT3o%x-8o1q=M)Pjz$!v`@O^^U>H{=Zj{sCZI^!` z!GbiJVqPm65y&f)zWXbOSBsO-;Vu=gKT?w|SB&D*ICsfsEXX$B*-L91l=pE9ES8Us zhnA;T3ah%X=-qdjWdGxC(7MML&aK)fA{88gJjUl&ti8RuS?%`YT<c*J4dwejNE&gc z^5uD!VjY$K5_zj`wR&uJc@Rh;u(l~#z_z3il>3p0vVuY-1$Tw!7~vD{xSw4p!G0Ga zouTInUr33PkX(H%C-=0`JJC{R!4+6EgTb&^&5Dj5e4G+rt(85Wldcd@;h=Oh-E@+h zw6JjG=#dW8kSr^ytuw89M|os(>L%BtuCLdGnog19mP(zr0y|jQB+1CGC#QT~%rXuM zHYU|*c+#;a>zUUEJ7dl>8+?G>e=KlpTvp;SrSzlB>*;KiBa_V4#xEFJ9f?4H4*5$d zLCrZWP66mG^`#4K2Y6fDMehX}Rh@Gq4?dQO9+k#VpzRCF)~vH7F8++ItyIJ>!OY%1 z_VPL=Oy_i&K+U|tCz9+M7|+}{zg~Y^m1fmVl-%J=_hiLo<LZNk+D6tf=9jkgU~eCm zS=gN^LzU5gdUNA&FeW%Sz*iixIW}gu`S4A#^9lww0er_Us=#FZnpyGT*{T<(LIOo= znHu*XPoP@zt7(|;YPtS#H)CUCNu0MUc_!tHh09896D;h!q0}0N9mDVr@a~%u{fbum zh5Bvcn-r%nL14*W_N4SvowX|yYw^hX@!8OJF+}Ub{QP`nf@Z-$+1K2#uoZ>@Guk3P z#zj%DJJZe5-4DJvyi#o7xMQ+wb#lV&*`sO4-L1TpS2Y%ji#d#5@LVQiBK6jmx7Q#1 zm|Cy|dJtf&OSR5@Q+M=I)NdV149t0X+_`yudX7SYBd!XPMeM^!{L9GnY{Q&Qan*M6 zZ?TArb!DtBsxi`V<XCy#)DHmian|owIgiR&q)?Rj*wW1Vpx~PTUo656$<NC}we4*( zoNIIsGz0^rNL0P?D`m68C$&#-JVQ8mcdhip$OV9{2H_~(tIi*0x`|y*%UiwuWw`s< zN<?i1_Ktk6)FHe;vH~~Hb*bq_FQy2xz6ghDSCJa60Ae*tRwK>@)>DTXT}^UBqci_A zx@pMY^XloyN2*RG+)@PPWb&0}X$j=+6beO&TxWRzATpVf=8jvX6FO!QQ=c!vC4x^1 zz}7^=o~FgNH{>dImM^R^PXR^6+D0q8l`t~?O}+Yi{2?~XNKiZ2LP%C_OY`hlyZ!Bx zLZBx-7oBk?a`X`_j%`_HBRCp6b6}@e68k;L|FxHbhybX;`7d}=$m0d#NJmq>=6P$E zO~MBvKMW%TPCBNAl#_+Ae!aDj@PZFCsyi%Wl`IyVR_#P|6<?|ypkA;^tVGzkESR%k zVh=AGw>NY(5;~-i8PiAHz5oPTQBCXEyS!G?QImRIQpfU~2#Mz~nj+rG1N)n){UQRl zvR-xi3cO}1oV`_;I=fm4-svu>y`lJ}W2&>EtIvLEXFx*g`Si%%LY*Mj8KP%HQ+>H^ zLwitu7T<;%=5FzIA=$vKr1@KtKvnZ@Ykn&OF{Qc<@l35LoXsD;`vk5f096$WQ!)v* zG-?K_1T}X^pJO|%H$2{s2yf8^1Adak{xtH6ZK_%KbkYH+$aplzv(CRAS=SWWh=TS% zP3y;f!OwkZ1u|K7zh#c6J$>=GlnI`=EEq~=Kl-pJVGK~_v&JNodZL+@?$L&>pEZ}H zV)t&k4BNQGhh*I}keGoUA*%^@!NTkV$qGP`X+z%=Uqbvu!<<d5<q3m8Jl?NE&F%oj e71|qz1wiA^8h4JaS{4I$K-UdS^-F%Y{qw(bW|o8i diff --git a/README.md b/README.md index 057fc7a..b181012 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/frontend/assets/app.js b/frontend/assets/app.js index babc168..d613103 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -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"; diff --git a/frontend/assets/document-modal.js b/frontend/assets/document-modal.js index a737d0d..ddb20a9 100644 --- a/frontend/assets/document-modal.js +++ b/frontend/assets/document-modal.js @@ -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(() => { diff --git a/frontend/assets/document.js b/frontend/assets/document.js index 0c0df47..efd6e89 100644 --- a/frontend/assets/document.js +++ b/frontend/assets/document.js @@ -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()}`; } diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index 9b01b70..3b4d3f4 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -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; } diff --git a/frontend/document.html b/frontend/document.html index d55efe6..b6669fc 100644 --- a/frontend/document.html +++ b/frontend/document.html @@ -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> diff --git a/tests/e2e/test_document_viewer.py b/tests/e2e/test_document_viewer.py index 0aa4e84..c6c2462 100644 --- a/tests/e2e/test_document_viewer.py +++ b/tests/e2e/test_document_viewer.py @@ -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. diff --git a/tests/e2e/test_document_viewer_context.py b/tests/e2e/test_document_viewer_context.py new file mode 100644 index 0000000..6882722 --- /dev/null +++ b/tests/e2e/test_document_viewer_context.py @@ -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) diff --git a/tests/unit/test_document_viewer.py b/tests/unit/test_document_viewer.py index 4753dab..5af5cf4 100644 --- a/tests/unit/test_document_viewer.py +++ b/tests/unit/test_document_viewer.py @@ -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 diff --git a/tests/unit/test_frontend_doc_modal.py b/tests/unit/test_frontend_doc_modal.py index b396b6e..9f7c1e7 100644 --- a/tests/unit/test_frontend_doc_modal.py +++ b/tests/unit/test_frontend_doc_modal.py @@ -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"], + ] + diff --git a/tests/unit/test_image_chips_and_answers.py b/tests/unit/test_image_chips_and_answers.py index a415779..9a0bc5f 100644 --- a/tests/unit/test_image_chips_and_answers.py +++ b/tests/unit/test_image_chips_and_answers.py @@ -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 diff --git a/tests/unit/test_image_documents.py b/tests/unit/test_image_documents.py index 7f0e42a..89e4b68 100644 --- a/tests/unit/test_image_documents.py +++ b/tests/unit/test_image_documents.py @@ -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: diff --git a/tests/unit/test_source_chip_quality.py b/tests/unit/test_source_chip_quality.py index d092f2e..0d23451 100644 --- a/tests/unit/test_source_chip_quality.py +++ b/tests/unit/test_source_chip_quality.py @@ -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)" )