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 1c506d8..a719bc1 100644 Binary files a/.agents/screenshots/doc_modal_scrollbar_1280.png and b/.agents/screenshots/doc_modal_scrollbar_1280.png differ 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)" )