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