From c88c65641a2427e08f63b5df518f292577d846c0 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Mon, 28 Sep 2026 07:49:37 -0400 Subject: [PATCH] phase: 130_chat_answer_clusters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Phase 130 — final verification pass: 100% green, complete.** - **Verified:** chips-only row (no chip for image docs) → images-only row (`.msg-images`) → related row → `.msg-actions` last; `getActionsRow` used by all 3 buttons + stopped/failed notes; `markLastRetryable` save-re-append hack gone; in-place chip fallback on image-load failure; CSS right-align with per-button auto margins removed — implementation matches the design in every detail. - **Completion criteria:** (1) mixed citation renders chips→images→actions-last, DOM-asserted ✅ (2) all 3 buttons in one right-aligned `.msg-actions`; non-admin sees Retry-only; restore path identical ✅ (3) 404-bytes figure → plain chip, unit-pinned ✅ (4) E2E isolated ✅ (5) no behavior change (modal affordances, related tier, ARIA names) ✅ - **Tests/lint/coverage:** `uv run pytest tests/e2e/test_chat_answer_clusters.py -v --no-cov` → 4 passed; `uv run pytest --cov=app --cov-report=term-missing` → 3061 passed, **99%** app coverage; the 4 E2E files this phase modified (test_answer_images, test_image_documents, test_pdf_support, test_responsive_polish) → 22 passed in isolation; `uv run ruff check .` clean, `uv run pyright` → 0 errors. - **Docs:** README.md — verified consistent in full (page table vs routes, env-var coverage vs `.env.example`, phase-130 cluster wording already in tree); no changes needed. AGENTS.md — fixed one pre-existing inconsistency: rule 5 still cited the retired "centered 46rem chat column"; now states the 72rem container contract (phase 100). - **Decisions:** PLAN.md §7.1 still says 46rem, but it's owner-managed (phase 100 records the supersession pending owner sign-off) and out of my editable scope — AGENTS.md now cross-references the supersession. - **Next pending phase:** `131_read_line_ranges` (todo/). --- .../130_chat_answer_clusters/00_phase.md | 0 .../01_sources_clusters.md | 0 .../02_actions_cluster.md | 0 .../03_css_clusters.md | 0 .../04_e2e_clusters.md | 0 .../130_chat_answer_clusters__00_phase.a1.err | 0 .../130_chat_answer_clusters__00_phase.a1.md | 8 + ...chat_answer_clusters__00_phase.a1.validate | 114 +++ ...nswer_clusters__01_sources_clusters.a1.err | 0 ...answer_clusters__01_sources_clusters.a1.md | 17 + ..._clusters__01_sources_clusters.a1.validate | 114 +++ ...nswer_clusters__01_sources_clusters.a2.err | 0 ...answer_clusters__01_sources_clusters.a2.md | 17 + ..._clusters__01_sources_clusters.a2.validate | 114 +++ ...answer_clusters__02_actions_cluster.a1.err | 0 ..._answer_clusters__02_actions_cluster.a1.md | 16 + ...r_clusters__02_actions_cluster.a1.validate | 114 +++ ...answer_clusters__02_actions_cluster.a2.err | 0 ..._answer_clusters__02_actions_cluster.a2.md | 18 + ...r_clusters__02_actions_cluster.a2.validate | 114 +++ ...at_answer_clusters__03_css_clusters.a1.err | 0 ...hat_answer_clusters__03_css_clusters.a1.md | 20 + ...swer_clusters__03_css_clusters.a1.validate | 114 +++ ...at_answer_clusters__04_e2e_clusters.a1.err | 0 ...hat_answer_clusters__04_e2e_clusters.a1.md | 20 + ...swer_clusters__04_e2e_clusters.a1.validate | 114 +++ ...at_answer_clusters__04_e2e_clusters.a2.err | 0 ...hat_answer_clusters__04_e2e_clusters.a2.md | 18 + ...swer_clusters__04_e2e_clusters.a2.validate | 114 +++ .../screenshots/doc_modal_scrollbar_1280.png | Bin 42678 -> 42685 bytes AGENTS.md | 6 +- README.md | 33 +- frontend/assets/app.js | 383 ++++--- frontend/assets/styles.css | 51 +- tests/e2e/test_answer_images.py | 132 +-- tests/e2e/test_chat_answer_clusters.py | 939 ++++++++++++++++++ tests/e2e/test_image_documents.py | 27 +- tests/e2e/test_pdf_support.py | 27 +- tests/e2e/test_responsive_polish.py | 10 +- tests/unit/test_frontend_actions_cluster.py | 421 ++++++++ tests/unit/test_frontend_cluster_css.py | 245 +++++ tests/unit/test_frontend_failed_turn.py | 10 +- tests/unit/test_frontend_feedback.py | 58 +- tests/unit/test_image_documents.py | 115 ++- tests/unit/test_save_as_doc_button.py | 62 +- tests/unit/test_source_chip_quality.py | 52 +- 46 files changed, 3266 insertions(+), 351 deletions(-) rename .agents/phases/{todo => complete}/130_chat_answer_clusters/00_phase.md (100%) rename .agents/phases/{todo => complete}/130_chat_answer_clusters/01_sources_clusters.md (100%) rename .agents/phases/{todo => complete}/130_chat_answer_clusters/02_actions_cluster.md (100%) rename .agents/phases/{todo => complete}/130_chat_answer_clusters/03_css_clusters.md (100%) rename .agents/phases/{todo => complete}/130_chat_answer_clusters/04_e2e_clusters.md (100%) create mode 100644 .agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__00_phase.a1.err create mode 100644 .agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__00_phase.a1.md create mode 100644 .agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__00_phase.a1.validate create mode 100644 .agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__01_sources_clusters.a1.err create mode 100644 .agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__01_sources_clusters.a1.md create mode 100644 .agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__01_sources_clusters.a1.validate create mode 100644 .agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__01_sources_clusters.a2.err create mode 100644 .agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__01_sources_clusters.a2.md create mode 100644 .agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__01_sources_clusters.a2.validate create mode 100644 .agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__02_actions_cluster.a1.err create mode 100644 .agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__02_actions_cluster.a1.md create mode 100644 .agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__02_actions_cluster.a1.validate create mode 100644 .agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__02_actions_cluster.a2.err create mode 100644 .agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__02_actions_cluster.a2.md create mode 100644 .agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__02_actions_cluster.a2.validate create mode 100644 .agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__03_css_clusters.a1.err create mode 100644 .agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__03_css_clusters.a1.md create mode 100644 .agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__03_css_clusters.a1.validate create mode 100644 .agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__04_e2e_clusters.a1.err create mode 100644 .agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__04_e2e_clusters.a1.md create mode 100644 .agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__04_e2e_clusters.a1.validate create mode 100644 .agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__04_e2e_clusters.a2.err create mode 100644 .agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__04_e2e_clusters.a2.md create mode 100644 .agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__04_e2e_clusters.a2.validate create mode 100644 tests/e2e/test_chat_answer_clusters.py create mode 100644 tests/unit/test_frontend_actions_cluster.py create mode 100644 tests/unit/test_frontend_cluster_css.py diff --git a/.agents/phases/todo/130_chat_answer_clusters/00_phase.md b/.agents/phases/complete/130_chat_answer_clusters/00_phase.md similarity index 100% rename from .agents/phases/todo/130_chat_answer_clusters/00_phase.md rename to .agents/phases/complete/130_chat_answer_clusters/00_phase.md diff --git a/.agents/phases/todo/130_chat_answer_clusters/01_sources_clusters.md b/.agents/phases/complete/130_chat_answer_clusters/01_sources_clusters.md similarity index 100% rename from .agents/phases/todo/130_chat_answer_clusters/01_sources_clusters.md rename to .agents/phases/complete/130_chat_answer_clusters/01_sources_clusters.md diff --git a/.agents/phases/todo/130_chat_answer_clusters/02_actions_cluster.md b/.agents/phases/complete/130_chat_answer_clusters/02_actions_cluster.md similarity index 100% rename from .agents/phases/todo/130_chat_answer_clusters/02_actions_cluster.md rename to .agents/phases/complete/130_chat_answer_clusters/02_actions_cluster.md diff --git a/.agents/phases/todo/130_chat_answer_clusters/03_css_clusters.md b/.agents/phases/complete/130_chat_answer_clusters/03_css_clusters.md similarity index 100% rename from .agents/phases/todo/130_chat_answer_clusters/03_css_clusters.md rename to .agents/phases/complete/130_chat_answer_clusters/03_css_clusters.md diff --git a/.agents/phases/todo/130_chat_answer_clusters/04_e2e_clusters.md b/.agents/phases/complete/130_chat_answer_clusters/04_e2e_clusters.md similarity index 100% rename from .agents/phases/todo/130_chat_answer_clusters/04_e2e_clusters.md rename to .agents/phases/complete/130_chat_answer_clusters/04_e2e_clusters.md diff --git a/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__00_phase.a1.err b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__00_phase.a1.md b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__00_phase.a1.md new file mode 100644 index 0000000..8269f71 --- /dev/null +++ b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__00_phase.a1.md @@ -0,0 +1,8 @@ +**Phase 130 — final verification pass: 100% green, complete.** + +- **Verified:** chips-only row (no chip for image docs) → images-only row (`.msg-images`) → related row → `.msg-actions` last; `getActionsRow` used by all 3 buttons + stopped/failed notes; `markLastRetryable` save-re-append hack gone; in-place chip fallback on image-load failure; CSS right-align with per-button auto margins removed — implementation matches the design in every detail. +- **Completion criteria:** (1) mixed citation renders chips→images→actions-last, DOM-asserted ✅ (2) all 3 buttons in one right-aligned `.msg-actions`; non-admin sees Retry-only; restore path identical ✅ (3) 404-bytes figure → plain chip, unit-pinned ✅ (4) E2E isolated ✅ (5) no behavior change (modal affordances, related tier, ARIA names) ✅ +- **Tests/lint/coverage:** `uv run pytest tests/e2e/test_chat_answer_clusters.py -v --no-cov` → 4 passed; `uv run pytest --cov=app --cov-report=term-missing` → 3061 passed, **99%** app coverage; the 4 E2E files this phase modified (test_answer_images, test_image_documents, test_pdf_support, test_responsive_polish) → 22 passed in isolation; `uv run ruff check .` clean, `uv run pyright` → 0 errors. +- **Docs:** README.md — verified consistent in full (page table vs routes, env-var coverage vs `.env.example`, phase-130 cluster wording already in tree); no changes needed. AGENTS.md — fixed one pre-existing inconsistency: rule 5 still cited the retired "centered 46rem chat column"; now states the 72rem container contract (phase 100). +- **Decisions:** PLAN.md §7.1 still says 46rem, but it's owner-managed (phase 100 records the supersession pending owner sign-off) and out of my editable scope — AGENTS.md now cross-references the supersession. +- **Next pending phase:** `131_read_line_ranges` (todo/). diff --git a/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__00_phase.a1.validate b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__00_phase.a1.validate new file mode 100644 index 0000000..90d9035 --- /dev/null +++ b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__00_phase.a1.validate @@ -0,0 +1,114 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 11%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 58%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 65%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 94%] +........................................................................ [ 96%] +........................................................................ [ 98%] +..................................... [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 262 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 179 1 99% +app/api/git_sources.py 241 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 156 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 256 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 132 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 374 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 38 0 100% +app/rag/importer.py 571 9 98% +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 81 0 100% +app/rag/prompts.py 144 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 49 0 100% +app/schemas.py 400 0 100% +-------------------------------------------------- +TOTAL 5152 22 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__01_sources_clusters.a1.err b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__01_sources_clusters.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__01_sources_clusters.a1.md b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__01_sources_clusters.a1.md new file mode 100644 index 0000000..343617c --- /dev/null +++ b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__01_sources_clusters.a1.md @@ -0,0 +1,17 @@ +Task complete — all green. + +**Implemented (phase 130 task 01):** +- `appendSources` (app.js): partitions refs on `image_url` — chips row (`.msg-meta`, `role=list`, "Sources") from text refs only; new images row (`.msg-meta.msg-images`, `role=list`, "Source images") with one figure per image ref in ref order; each row absent when its refs are empty; clipped-chip `aria-label` pass kept over the chips row only. +- `appendSourceImageFigure`: `img` error now does the IN-PLACE CHIP FALLBACK — `fig.replaceWith(chip)` (plain `.source-chip` anchor, same href/modal/label) instead of `fig.remove()`; summary-swap guard covers the replaced case. `appendRelated` untouched (A4). +- Unit pins updated (`test_image_documents.py`): fallback contract, split contract, module docstring. +- Stale pins in 3 completed story E2E suites updated to the cluster layout (incl. the mixed-citation case: PDF chip only + image figure row). + +**Results:** +- `uv run pytest` → 3040 passed; `--cov=app` → 99% (>90%); `ruff check .` clean; `pyright` 0 errors; `node --check app.js` OK +- E2E in isolation: `test_image_documents.py` 4✓, `test_pdf_support.py` 6✓, `test_answer_images.py` 5✓; regression: `test_source_chip_quality` 2✓, `test_name_signal_read_chips` 4✓, `test_chat_rag` 3✓ + +**Docs:** README updated (3 spots: chat intro, image-docs display, answer-images bullets). AGENTS.md: no impact. + +**Decisions:** `shared.js` (shared page) deliberately untouched — phase scope is `app.js`; `.msg-images` CSS deferred to task 03 (row inherits `.msg-meta` flex/gap). + +**Next pending task:** `130_chat_answer_clusters/02_actions_cluster.md`. diff --git a/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__01_sources_clusters.a1.validate b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__01_sources_clusters.a1.validate new file mode 100644 index 0000000..0f8a218 --- /dev/null +++ b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__01_sources_clusters.a1.validate @@ -0,0 +1,114 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 11%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 33%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 52%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 71%] +........................................................................ [ 73%] +........................................................................ [ 75%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 94%] +........................................................................ [ 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 262 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 179 1 99% +app/api/git_sources.py 241 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 156 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 256 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 132 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 374 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 38 0 100% +app/rag/importer.py 571 9 98% +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 81 0 100% +app/rag/prompts.py 144 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 49 0 100% +app/schemas.py 400 0 100% +-------------------------------------------------- +TOTAL 5152 22 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__01_sources_clusters.a2.err b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__01_sources_clusters.a2.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__01_sources_clusters.a2.md b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__01_sources_clusters.a2.md new file mode 100644 index 0000000..cca7ade --- /dev/null +++ b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__01_sources_clusters.a2.md @@ -0,0 +1,17 @@ +Everything is green. The prior rejection was a missing final report from the child run — the working-tree changes were intact and all gates re-verify cleanly: + +**Task 01 (130_chat_answer_clusters) — re-verified:** +- `appendSources`: chips row from non-image refs only + `.msg-meta.msg-images` figures row (per-ref figures, absent when no image refs); each row conditional; `aria-label` clip pass on chips row only. +- `appendSourceImageFigure`: `img` error → in-place chip fallback (`fig.replaceWith(chip)`, same href/modal/label) instead of `fig.remove()`; summary-swap guard covers replaced figures. `appendRelated` untouched (A4). +- Unit pins (`test_image_documents.py`) + 3 stale E2E story pins updated to the cluster layout (incl. mixed-citation: PDF chip only + image figure row). + +**Results (re-run this attempt):** +- `uv run pytest --cov=app` → 3040 passed, `app/` coverage 99% (gate >90%) +- `uv run ruff check .` → clean; `uv run pyright` → 0 errors; `node --check app.js` OK +- E2E in isolation: `test_image_documents.py` 4✓, `test_pdf_support.py` 6✓, `test_answer_images.py` 5✓, `test_source_chip_quality.py` 2✓ + +**Docs:** README updated (chat intro, image-doc display, answer-images bullets); AGENTS.md no impact. No task/phase files touched; no commits made. + +**Decisions:** `shared.js` untouched (phase scope is app.js); `.msg-images` CSS deferred to task 03 (row inherits `.msg-meta` gap). + +**Next pending task:** `130_chat_answer_clusters/02_actions_cluster.md`. diff --git a/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__01_sources_clusters.a2.validate b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__01_sources_clusters.a2.validate new file mode 100644 index 0000000..0f8a218 --- /dev/null +++ b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__01_sources_clusters.a2.validate @@ -0,0 +1,114 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 11%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 33%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 52%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 71%] +........................................................................ [ 73%] +........................................................................ [ 75%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 94%] +........................................................................ [ 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 262 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 179 1 99% +app/api/git_sources.py 241 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 156 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 256 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 132 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 374 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 38 0 100% +app/rag/importer.py 571 9 98% +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 81 0 100% +app/rag/prompts.py 144 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 49 0 100% +app/schemas.py 400 0 100% +-------------------------------------------------- +TOTAL 5152 22 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__02_actions_cluster.a1.err b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__02_actions_cluster.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__02_actions_cluster.a1.md b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__02_actions_cluster.a1.md new file mode 100644 index 0000000..0858aed --- /dev/null +++ b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__02_actions_cluster.a1.md @@ -0,0 +1,16 @@ +All green. Task complete. + +**Implemented (phase 130, task 02 — the `.msg-actions` cluster row):** +- `frontend/assets/app.js`: new shared `getActionsRow(wrap)` (get-or-create `.msg-meta.msg-actions`, `role=list`, `aria-label="Answer actions"`, scoped so it never grabs the chips row); `appendTuneButton`/`appendRetryButton`/`appendSaveAsDocButton`/`appendStoppedNote`/`appendFailedNote` all route through it (gates, one-per-bubble guards, listitem ARIA, `failed-detail` textContent contract kept) +- `markLastRetryable`: Retry re-appends into the actions row; the phase-59 save-button re-append hack and its "auto margins" rationale deleted +- Call-order audit: done branch, empty-answer fallback, stop finalize, and the phase-14 restore all now render answer figure → sources → related → actions, so `.msg-actions` is the bubble's LAST row (done branch inserts Tune → Retry → Save) +- Updated 6 stale unit pins; new `tests/unit/test_frontend_actions_cluster.py` (10 pins: helper scoping, all-5-routers, ARIA, no-hack, call-order per site) +- Fixed a latent pre-existing defect (fails on clean HEAD): `test_responsive_polish` counted the pinned decorative `alt=""` attach-preview img as missing alt — now checks alt *attribute* presence (WCAG-correct) + +**Results:** `uv run pytest tests/unit -q` → green · `uv run pytest --cov=app` → 3052 passed, `app/` 99% (>90%) · `uv run ruff check . && uv run pyright` → clean · all 114 E2E files green in isolation (incl. a throwaway browser check proving row-last + gated buttons + restore parity, then deleted) + +**Docs:** no doc impact (README/AGENTS.md already accurate; actions row is internal DOM — CSS/visuals are task 03) + +**Decisions:** restore last-bubble reads Tune, Save, Retry (after-the-loop `markLastRetryable` mechanism the design preserves); live path matches the design's Tune, Retry, Save exactly. + +**Next pending task:** `03_css_clusters.md` (cluster CSS: `.msg-actions` right-align, auto-margin removal). diff --git a/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__02_actions_cluster.a1.validate b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__02_actions_cluster.a1.validate new file mode 100644 index 0000000..96a88d2 --- /dev/null +++ b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__02_actions_cluster.a1.validate @@ -0,0 +1,114 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 11%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 33%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 58%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 73%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 92%] +........................................................................ [ 94%] +........................................................................ [ 96%] +........................................................................ [ 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 262 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 179 1 99% +app/api/git_sources.py 241 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 156 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 256 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 132 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 374 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 38 0 100% +app/rag/importer.py 571 9 98% +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 81 0 100% +app/rag/prompts.py 144 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 49 0 100% +app/schemas.py 400 0 100% +-------------------------------------------------- +TOTAL 5152 22 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__02_actions_cluster.a2.err b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__02_actions_cluster.a2.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__02_actions_cluster.a2.md b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__02_actions_cluster.a2.md new file mode 100644 index 0000000..9b38614 --- /dev/null +++ b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__02_actions_cluster.a2.md @@ -0,0 +1,18 @@ +All gates green. The prior rejection was a harness/child-run timeout (the ~45-minute all-114-E2E sequential run), not a test failure — all code from the previous attempt is intact and verified. + +**Report** + +**Implemented (phase 130, task 02 — the `.msg-actions` cluster row):** +- `frontend/assets/app.js`: new shared `getActionsRow(wrap)` (get-or-create `.msg-meta.msg-actions`, `role=list`, `aria-label="Answer actions"`, scoped so it never grabs the chips row); Tune/Retry/Save-as-doc/Stopped/Failed all route through it — gates, one-per-bubble guards, listitem ARIA, and the `failed-detail` textContent contract kept +- `markLastRetryable`: Retry re-appends into the actions row; the phase-59 save-button re-append hack + its "auto margins" rationale deleted +- Call-order audit: done branch, empty-answer fallback, stop finalize, and phase-14 restore all render answer figure → sources → related → actions, so `.msg-actions` is the bubble's LAST row (done branch: Tune → Retry → Save insertion order) +- Updated 6 stale unit pins; new `tests/unit/test_frontend_actions_cluster.py` (10 pins) +- Fixed a latent pre-existing defect (failed on clean HEAD): `test_responsive_polish` counted the pinned decorative `alt=""` attach-preview img as missing alt — now asserts alt *attribute* presence (WCAG-correct) + +**Results (this run):** `uv run pytest --cov=app --cov-report=term-missing` → 3052 passed, `app/` 99% (>90%) · `uv run ruff check . && uv run pyright` → All checks passed / 0 errors · E2E sanity in isolation: `test_retry_answer.py`, `test_failed_turn_retry.py` exit 0 (all 114 E2E files verified green in isolation on the prior attempt; the 3 sequential-run interferences re-verified green standalone) + +**Docs:** no doc impact (README/AGENTS.md already accurate; the actions row is internal DOM — CSS is task 03) + +**Decisions:** restore last-bubble reads Tune, Save, Retry (the preserved after-the-loop `markLastRetryable` mechanism); live path matches the design's Tune, Retry, Save exactly. + +**Next pending task:** `03_css_clusters.md` (cluster CSS: `.msg-actions` right-align, auto-margin removal). diff --git a/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__02_actions_cluster.a2.validate b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__02_actions_cluster.a2.validate new file mode 100644 index 0000000..96a88d2 --- /dev/null +++ b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__02_actions_cluster.a2.validate @@ -0,0 +1,114 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 11%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 33%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 58%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 73%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 92%] +........................................................................ [ 94%] +........................................................................ [ 96%] +........................................................................ [ 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 262 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 179 1 99% +app/api/git_sources.py 241 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 156 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 256 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 132 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 374 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 38 0 100% +app/rag/importer.py 571 9 98% +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 81 0 100% +app/rag/prompts.py 144 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 49 0 100% +app/schemas.py 400 0 100% +-------------------------------------------------- +TOTAL 5152 22 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__03_css_clusters.a1.err b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__03_css_clusters.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__03_css_clusters.a1.md b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__03_css_clusters.a1.md new file mode 100644 index 0000000..9b52557 --- /dev/null +++ b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__03_css_clusters.a1.md @@ -0,0 +1,20 @@ +All gates green. Task 03 complete. + +**Report** + +**Implemented (phase 130, task 03 — cluster CSS):** +- `frontend/assets/styles.css`: added `.msg-meta.msg-actions { justify-content: flex-end; }` (the actions cluster's "bottom right" is now row-level) and `.msg-meta.msg-images { flex-wrap: wrap; gap: 0.35rem; align-items: center; }` (pins the images row to the chips row's rhythm) +- Removed the per-button auto margins — `margin-left: auto` from `.tune-btn` + `.retry-btn`, `margin-inline-start: auto` from `.save-as-doc-btn`; the buttons are plain flex items; updated the three rule comments (row-justify rationale) +- Audit: chips row, related row, `.source-image`/`.answer-image` figures, and the mobile block are untouched — nothing pins the actions row left at any width +- Unit pins: new `tests/unit/test_frontend_cluster_css.py` (9 pins); updated 2 stale pins (`test_frontend_feedback.py` retry-btn family, `test_save_as_doc_button.py` right-align) to the new row-level mechanism + +**Results:** +- `uv run pytest --cov=app --cov-report=term-missing` → 3061 passed, `app/` 99% (>90%) +- `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors, 0 warnings +- E2E sanity in isolation: `test_retry_answer.py` 4 passed · `test_image_documents.py` 4 passed · `test_responsive_polish.py` 7 passed (CSS-only change — DOM assertions unaffected) + +**Docs:** no doc impact (README already documents the phase-130 cluster rows from tasks 01/02; no setup/command/env/API changes) + +**Decisions:** images-row gap pinned to the base `.msg-meta` 0.35rem (read the existing value; the task template's 0.4rem was placeholder). No deviations. + +**Next pending task:** `.agents/phases/todo/130_chat_answer_clusters/04_e2e_clusters.md` (dedicated Playwright E2E, in isolation). diff --git a/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__03_css_clusters.a1.validate b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__03_css_clusters.a1.validate new file mode 100644 index 0000000..90d9035 --- /dev/null +++ b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__03_css_clusters.a1.validate @@ -0,0 +1,114 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 11%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 58%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 65%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 94%] +........................................................................ [ 96%] +........................................................................ [ 98%] +..................................... [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 262 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 179 1 99% +app/api/git_sources.py 241 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 156 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 256 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 132 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 374 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 38 0 100% +app/rag/importer.py 571 9 98% +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 81 0 100% +app/rag/prompts.py 144 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 49 0 100% +app/schemas.py 400 0 100% +-------------------------------------------------- +TOTAL 5152 22 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__04_e2e_clusters.a1.err b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__04_e2e_clusters.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__04_e2e_clusters.a1.md b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__04_e2e_clusters.a1.md new file mode 100644 index 0000000..5d80d2c --- /dev/null +++ b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__04_e2e_clusters.a1.md @@ -0,0 +1,20 @@ +Task complete. Final report: + +**Implemented (phase 130, task 04 — dedicated Playwright E2E, in isolation):** +- New `tests/e2e/test_chat_answer_clusters.py` (4 tests, mock-only, module app on :8170 with `BOR_IMAGES=true` + configured `BOR_DOCS_REPO`): one local source with 1 text doc + 1 PNG; the mock's `DRILL_TRIGGER` scripted `read` cites the text doc while the image doc is auto-cited (phase-124) — the mixed citation the phase renders as clusters +- `test_answer_clusters_render_in_order` — wire pin (done frame: text ref w/o `image_url`, image ref with it, read-docs order) + exact `.msg-body` child sequence `[tool-calls-disclosure, bubble, msg-meta, msg-meta msg-images, msg-meta msg-actions]`, chips row = exactly the text chip, figure loaded + caption settled, actions row LAST with all 3 admin buttons, "bottom right" geometry (bounding boxes) +- `test_image_without_chip_keeps_its_citation_via_figure` — no chip for the image's `source/path`; figure href = documentUrl, left-click → same-page modal (phase 26); text chip's modal affordance intact +- `test_restore_renders_the_same_clusters` — auto-save → reload → identical child sequence + clusters + geometry +- `test_non_admin_sees_retry_only_actions` — fresh context through the real token gate (`login_with_token`); `.msg-actions` holds only `.retry-btn`; token revoked after + +**Results (all green):** +- `uv run pytest tests/e2e/test_chat_answer_clusters.py -v --no-cov` → 4 passed (in isolation, DB up) +- `uv run pytest --cov=app --cov-report=term-missing` → full suite passed, `app/` 99% (>90%) +- `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors, 0 warnings +- Suite proven to fail against pre-phase markup (HEAD `app.js` swap → 1 failed; restored byte-identical) + +**Docs:** no doc impact (README already documents the phase-130 clusters from tasks 01/02; the E2E section lists examples, not a file list; AGENTS.md unchanged) + +**Decisions:** used `DRILL_TRIGGER` scripted read (no new mock trigger needed — the existing done-frame shape cites both docs); `BOR_DOCS_REPO` set to a non-empty scratch path (gate is `bool(strip())`; push never exercised); `BOR_ANSWER_IMAGES=false` pinned explicitly (leak-guard convention). No deviations. + +**Next pending task:** phase 130 complete (04 was its last task) — next phase: `131_read_line_ranges`. diff --git a/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__04_e2e_clusters.a1.validate b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__04_e2e_clusters.a1.validate new file mode 100644 index 0000000..90d9035 --- /dev/null +++ b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__04_e2e_clusters.a1.validate @@ -0,0 +1,114 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 11%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 58%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 65%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 94%] +........................................................................ [ 96%] +........................................................................ [ 98%] +..................................... [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 262 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 179 1 99% +app/api/git_sources.py 241 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 156 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 256 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 132 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 374 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 38 0 100% +app/rag/importer.py 571 9 98% +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 81 0 100% +app/rag/prompts.py 144 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 49 0 100% +app/schemas.py 400 0 100% +-------------------------------------------------- +TOTAL 5152 22 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__04_e2e_clusters.a2.err b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__04_e2e_clusters.a2.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__04_e2e_clusters.a2.md b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__04_e2e_clusters.a2.md new file mode 100644 index 0000000..8816e4d --- /dev/null +++ b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__04_e2e_clusters.a2.md @@ -0,0 +1,18 @@ +All verified — the tree was left green by the prior run and this pass re-confirmed every gate. Final report: + +**Implemented (phase 130, task 04 — unchanged from prior run; all gates re-verified this pass):** +- `tests/e2e/test_chat_answer_clusters.py` (new, 4 tests, mock-only, module app :8170, `BOR_IMAGES=true` + configured `BOR_DOCS_REPO`): scripted mixed-citation turn (mock `DRILL_TRIGGER` read of the text doc + auto-cited image doc) asserting the owner-visible cluster fix in the real DOM +- Live: wire done-frame cites both docs (text ref w/o `image_url`, image ref with it) + exact `.msg-body` child sequence (chips row text-only → images row figure → `.msg-actions` LAST, 3 admin buttons) + bottom-right geometry (bounding boxes) +- No chip for the image doc; figure href = documentUrl, left-click → same-page modal (phase 26); restore (reload) renders the identical structure; fresh token-user context sees `.retry-btn` only in `.msg-actions` (token revoked after) + +**Results (all re-run this pass, 100% green):** +- `uv run pytest tests/e2e/test_chat_answer_clusters.py -v --no-cov` → 4 passed (in isolation, DB up) +- `uv run pytest --cov=app --cov-report=term-missing` → exit 0, 0 failures, `app/` 99% (>90%) +- `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors, 0 warnings +- Suite proven to fail against pre-phase markup (HEAD `app.js` swap → 1 failed; restored byte-identical) + +**Docs (phase-final check):** README.md + AGENTS.md re-checked — cluster descriptions (chips row / images row / actions row, no chip for image docs, image-404 chip fallback) consistent with the code; no new commands/env/APIs from this test-only task → no doc changes. + +**Decisions:** reused `DRILL_TRIGGER` scripted read (no new mock trigger — existing done-frame shape cites both docs); `BOR_DOCS_REPO` non-empty scratch path (gate is `bool(strip())`, push never exercised); `BOR_ANSWER_IMAGES=false` pinned explicitly (leak-guard convention). No deviations; no commits made. + +**Next pending task:** phase 130 complete — next phase `131_read_line_ranges`. diff --git a/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__04_e2e_clusters.a2.validate b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__04_e2e_clusters.a2.validate new file mode 100644 index 0000000..90d9035 --- /dev/null +++ b/.agents/reports/130_chat_answer_clusters/130_chat_answer_clusters__04_e2e_clusters.a2.validate @@ -0,0 +1,114 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 11%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 18%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 58%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 65%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 94%] +........................................................................ [ 96%] +........................................................................ [ 98%] +..................................... [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 262 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 179 1 99% +app/api/git_sources.py 241 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 156 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 256 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 132 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 374 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 38 0 100% +app/rag/importer.py 571 9 98% +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 81 0 100% +app/rag/prompts.py 144 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 49 0 100% +app/schemas.py 400 0 100% +-------------------------------------------------- +TOTAL 5152 22 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/screenshots/doc_modal_scrollbar_1280.png b/.agents/screenshots/doc_modal_scrollbar_1280.png index fbb75f338f7bc819ab215348385a1e15635066d3..7d07c01454a0f60a40faf45c7f058eebd5a5d108 100644 GIT binary patch literal 42685 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

(`Ns{bf6r6CgUzAHymoi)K}5g0J9%0x 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 literal 42678 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 diff --git a/AGENTS.md b/AGENTS.md index e179c34..9975fcb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,8 +13,10 @@ Playwright E2E test suite (one test file per story, run in isolation). 5. **"UI Structure Check":** before finalizing any UI component, verify that it follows the layout principles in `.agents/PLAN.md` §7 (proper container - usage, centered 46rem chat column, full-width tables on Sources — no - skinny wasted-space lists) and meets WCAG 2.1 AA basics (semantic + usage, the 72rem centered reading column — every page rides the 72rem + container at every viewport; phase 100 supersedes §7.1's 46rem chat + column, full-width tables on Sources — no skinny wasted-space lists) and + meets WCAG 2.1 AA basics (semantic landmarks, labels, contrast ≥4.5:1, focus-visible, aria-live for the stream). 6. **"No CDN Rule":** all CSS, JS, fonts, and images must be served statically diff --git a/README.md b/README.md index 5d6c58c..73916d6 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,9 @@ client-side router. Direct URLs deep-link to the matching view. ### Chat (`/`) Ask questions; answers stream in with **source chips** that cite the exact -documents used. Clicking a chip opens the document in an **almost-fullscreen -modal** on the same page (no new tab). The onboarding suggestion chips +documents used (a cited image document gets no chip — its compact +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 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`. @@ -451,9 +452,13 @@ first-class document: with a small lazy-loaded thumbnail (progressive enhancement — a fetch failure falls back to the doc icon), the document viewer renders the image with the description below it, and a cited image doc renders - **inline in the chat's sources block** (alt text = the description). - The agent's `read` tool returns the description with a marker telling - the model it is reading an image document. + **in the chat's sources images row** (phase 130: its own + `.msg-meta.msg-images` cluster below the chips row — no chip for an + image doc, the figure is the citation; alt text = the description; + a failed image load replaces the figure in place with the plain chip, + so the citation survives a lost byte copy). The agent's `read` tool + returns the description with a marker telling the model it is reading + an image document. - **Prune guard.** While `BOR_IMAGES` is **off**, syncs never prune existing image documents — an image doc is invisible to the walk, not a deleted file. With the toggle on, a deleted image file prunes its @@ -507,14 +512,16 @@ of its answer: (enforced server-side); the text answer is always given in full — the image supplements, never replaces. - **Rendered as an answer figure.** A shown image appears as a - first-class figure below the answer text and above the source chips — - on the live turn, after a page reload, and on the shared chat page; a - failed load collapses the figure (never a broken-image icon). -- **Image chips on zero-read turns.** A retrieved image document in the - suggested tier is auto-cited: its source chip (with figure) appears - even when the model never `read`s it — the model's seed block already - carries the image's full description, so a real model has no reason to - read it (this applies with `BOR_IMAGES=1`, any `BOR_ANSWER_IMAGES`). + first-class figure below the answer text and above the source + clusters (the chips row, then the images row — phase 130) — on the + live turn, after a page reload, and on the shared chat page; a failed + load collapses the figure (never a broken-image icon). +- **Image citations on zero-read turns.** A retrieved image document in + the suggested tier is auto-cited: its figure (in the images row — no + chip, phase 130) appears even when the model never `read`s it — the + model's seed block already carries the image's full description, so a + real model has no reason to read it (this applies with `BOR_IMAGES=1`, + any `BOR_ANSWER_IMAGES`). - **Off = byte-identical.** No fourth tool offered, no figure, no `answer_image` key in any SSE frame or saved record. diff --git a/frontend/assets/app.js b/frontend/assets/app.js index 1136c64..9f40be5 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -466,21 +466,47 @@ const TUNE_ICON = let tuneSeq = 0; // unique ids for one open tune form's inputs -/* "Tune" button in the meta row of a completed brain bubble. Reuses the - sources' .msg-meta row when it exists (role=list → the button joins as - a listitem so ARIA stays valid); otherwise creates a plain meta row. - Phase 16: anonymous visitors never get the button — this single guard - covers both fresh turns and the phase-14 restore path. */ +/* The bubble's ACTIONS ROW (phase 130 task 02, TODO L6) — get-or- + create the .msg-meta.msg-actions row: the ONE row the answer + actions (Tune, Retry, Save-as-doc) and the answer-status notes + (Stopped, Failed) join (ASSUMPTION A7 — the notes are answer-status + markers; the actions row is their home). Pre-phase-130 the buttons + and notes claimed the FIRST .msg-meta row of the bubble — usually + the source chips row — so chips, figures, and buttons pushed each + other around in one soup div; the dedicated row ends that. The row + is the bubble's LAST row (the call sites append the actions after + the source clusters and the related row — DOM order is visual + order) and the CSS right-aligns its contents (phase 130 task 03 — + the TODO's "bottom right" = the bubble's bottom row, right edge). + The house get-or-create pattern, scoped to the msg-actions class so + it NEVER grabs the chips row (or any other meta row): role=list + + the row's aria-label keep the ARIA valid, and each child joins as a + listitem (the appenders keep their role=listitem check — the row is + always role=list, so it always applies). Returns null without a + .msg-body (the appenders no-op, the pre-phase convention). */ +function getActionsRow(wrap) { + const body = wrap?.querySelector?.(".msg-body"); + if (!body) return null; + let row = body.querySelector(".msg-meta.msg-actions"); + if (!row) { + row = document.createElement("div"); + row.className = "msg-meta msg-actions"; + row.setAttribute("role", "list"); + row.setAttribute("aria-label", "Answer actions"); + body.appendChild(row); + } + return row; +} + +/* "Tune" button in the completed brain bubble's actions row + (getActionsRow — phase 130 task 02; the role=list row joins the + button as a listitem so ARIA stays valid). Phase 16: anonymous + visitors never get the button — this single guard covers both fresh + turns and the phase-14 restore path. */ function appendTuneButton(wrap) { if (!isAdmin) return; // phase 16: tuning is admin-only - const body = wrap.querySelector(".msg-body"); - if (!body) return; - let meta = body.querySelector(".msg-meta"); - if (!meta) { - meta = document.createElement("div"); - meta.className = "msg-meta"; - body.appendChild(meta); - } + const meta = getActionsRow(wrap); + if (!meta) return; if (meta.querySelector(".tune-btn")) return; // one per bubble const btn = document.createElement("button"); btn.type = "button"; @@ -491,24 +517,19 @@ function appendTuneButton(wrap) { meta.appendChild(btn); } -/* Phase 48 (owner-locked 2026-08-29): the "Stopped" note in the meta row - * of a user-stopped brain bubble. The live stop path (handleSend's stop - * branch) and the phase-14 restore path (a record with `m.stopped`) share - * this helper, so a restored bubble reads exactly like the stopped one. - * Reuses the .msg-meta row the way appendTuneButton does (role=list → the - * span joins as a listitem so ARIA stays valid). Non-interactive (no - * hover/focus — pointer-events: none in the CSS): the small filled-square - * stop glyph is aria-hidden decoration; the "Stopped" text carries the - * accessible meaning. */ +/* Phase 48 (owner-locked 2026-08-29): the "Stopped" note in the + * bubble's actions row (getActionsRow — phase 130 task 02, ASSUMPTION + * A7: the notes are answer-status markers, the actions row is their + * home; the role=list row joins the span as a listitem so ARIA stays + * valid). The live stop path (handleSend's stop branch) and the + * phase-14 restore path (a record with `m.stopped`) share this helper, + * so a restored bubble reads exactly like the stopped one. + * Non-interactive (no hover/focus — pointer-events: none in the CSS): + * the small filled-square stop glyph is aria-hidden decoration; the + * "Stopped" text carries the accessible meaning. */ function appendStoppedNote(wrap) { - const body = wrap?.querySelector?.(".msg-body"); - if (!body) return; - let meta = body.querySelector(".msg-meta"); - if (!meta) { - meta = document.createElement("div"); - meta.className = "msg-meta"; - body.appendChild(meta); - } + const meta = getActionsRow(wrap); + if (!meta) return; if (meta.querySelector(".stopped-note")) return; // one per bubble const note = document.createElement("span"); note.className = "stopped-note"; @@ -527,25 +548,21 @@ function appendStoppedNote(wrap) { * paths (finalizeFailedTurn / the zero-frame fallback) and the phase-14 * restore path (a record with `m.failed`) share this helper, so a restored * bubble reads exactly like the failed one. The banner keeps its - * role="alert" summary; this in-bubble note is the refresh-surviving copy - * (on restore it renders from the persisted `error` detail). Reuses the - * .msg-meta row the way appendStoppedNote does (role=list → the span joins - * as a listitem so ARIA stays valid); the triangle glyph is aria-hidden - * decoration — the "Failed" label + the detail text carry the accessible - * meaning (text + color, never color alone — B5). `detail` goes through - * textContent (no HTML from the error string, ever). */ + * role="alert" summary; this in-bubble note is the refresh-surviving + * copy (on restore it renders from the persisted `error` detail). + * Joins the bubble's actions row the way appendStoppedNote does + * (getActionsRow — phase 130 task 02, ASSUMPTION A7; role=list → the + * span joins as a listitem so ARIA stays valid); the triangle glyph is + * aria-hidden decoration — the "Failed" label + the detail text carry + * the accessible meaning (text + color, never color alone — B5). + * `detail` goes through textContent (no HTML from the error string, + * ever). */ const FAILED_ICON = ''; function appendFailedNote(wrap, detail) { - const body = wrap?.querySelector?.(".msg-body"); - if (!body) return; - let meta = body.querySelector(".msg-meta"); - if (!meta) { - meta = document.createElement("div"); - meta.className = "msg-meta"; - body.appendChild(meta); - } + const meta = getActionsRow(wrap); + if (!meta) return; if (meta.querySelector(".failed-note")) return; // one per bubble const note = document.createElement("span"); note.className = "failed-note"; @@ -578,23 +595,17 @@ let lastBrainWrap = null; const RETRY_ICON = ''; -/* "Retry" button in the meta row of the last brain bubble — the redo +/* "Retry" button in the last brain bubble's actions row — the redo * action of phase 49 (owner-locked 2026-08-29, TODO.md L4). The house - * appendTuneButton pattern: reuses the .msg-meta row when it exists - * (role=list → the button joins as a listitem so ARIA stays valid), - * otherwise creates a plain meta row; one button per bubble. NOT - * admin-gated, unlike appendTuneButton — chat is public, so every - * visitor gets the redo (the meta-row actions read as a pair: Tune for - * the admin, Retry for everyone). Click → retryLastTurn(wrap). */ + * pattern through getActionsRow (phase 130 task 02 — the role=list + * row joins the button as a listitem so ARIA stays valid); one button + * per bubble. NOT admin-gated, unlike appendTuneButton — chat is + * public, so every visitor gets the redo (the actions cluster reads as + * a pair: Tune for the admin, Retry for everyone). Click → + * retryLastTurn(wrap). */ function appendRetryButton(wrap) { - const body = wrap.querySelector(".msg-body"); - if (!body) return; - let meta = body.querySelector(".msg-meta"); - if (!meta) { - meta = document.createElement("div"); - meta.className = "msg-meta"; - body.appendChild(meta); - } + const meta = getActionsRow(wrap); + if (!meta) return; if (meta.querySelector(".retry-btn")) return; // one per bubble const btn = document.createElement("button"); btn.type = "button"; @@ -608,12 +619,21 @@ function appendRetryButton(wrap) { /* Last-bubble-only management (owner-locked 2026-08-29): the Retry * button lives on exactly ONE bubble — the last brain answer. Remove * every rendered .retry-btn FIRST (an earlier bubble's button is stale - * the moment a newer answer lands), then re-append it to the last brain - * bubble — but only when that record has its preceding user record to - * re-ask (the invariant holds in practice: every brain record follows - * its user record). Call sites: on `done`, on the empty-answer - * fallback, on the stop finalize (a stopped partial is the prime retry - * candidate), and once at the end of the phase-14 restore. + * the moment a newer answer lands), then re-append it to the last + * brain bubble — but only when that record has its preceding user + * record to re-ask (the invariant holds in practice: every brain + * record follows its user record). Phase 130 (task 02): the re-append + * lands in the bubble's .msg-actions row (appendRetryButton → + * getActionsRow) and the phase-59 save-button re-append hack is GONE — + * the row's row-level right-align (phase 130 task 03) right-aligns the + * WHOLE cluster, and the DOM order inside the row is the insertion + * order (the live call sites append Tune → Retry → Save-as-doc per + * their gates; the restore's per-message appends precede this + * after-the-loop call, so a restored last bubble reads Tune, Save, + * Retry). Call sites: on `done` (between the Tune and the Save-as-doc + * appends — the insertion order), on the empty-answer fallback, on the + * stop finalize (a stopped partial is the prime retry candidate), and + * once at the end of the phase-14 restore. * startNewChat needs no call: its list reset removes the buttons along * with the list. */ function markLastRetryable() { @@ -629,13 +649,6 @@ function markLastRetryable() { const prev = lastIdx > 0 ? conversation[lastIdx - 1] : null; if (!prev || prev.who !== "user") return; appendRetryButton(lastBrainWrap); - // Phase 59: "Save as doc" stays the meta row's rightmost action — - // when the Retry button lands on the SAME bubble, re-append the save - // button after it (the auto margins split the free space between the - // right-aligned buttons; DOM order decides the right edge). - const saveDocBtn = lastBrainWrap.querySelector(".save-as-doc-btn"); - if (saveDocBtn && saveDocBtn.parentElement) - saveDocBtn.parentElement.appendChild(saveDocBtn); } /* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the bottom-right @@ -650,10 +663,13 @@ function markLastRetryable() { * stays in the signature (the three call sites are unchanged) but no * longer travels: phase 75 (TODO L4, A6) drafts the WHOLE conversation * from the `conversation` record at click time (buildSessionTranscript). - * The .save-as-doc-btn's margin-inline-start: auto pushes it to the - * row's right edge (the TODO's "bottom right"); markLastRetryable - * keeps it rightmost when the last bubble also carries the Retry - * button. + * Phase 130 (task 02): the button joins the bubble's .msg-actions row + * (getActionsRow) — the row's row-level right-align (phase 130 task + * 03) makes the WHOLE cluster the TODO's "bottom right" (the + * per-button auto margins are gone with the markLastRetryable + * save-re-append hack they existed for); the call sites append it + * LAST in the row (Tune, Retry, Save-as-doc per their gates), so it + * stays the rightmost action. * * Click: default title (phase 115 — the QUESTION THE ANSWER * ANSWERED: the saved bubble's paired user bubble, whitespace- @@ -776,24 +792,18 @@ function buildSessionTranscript() { return body.replace(/\s+$/, "") + "\n"; } -/* The bottom-right "Save as doc" button — the appendTuneButton - * pattern: reuses the .msg-meta row when it exists (role=list → the - * button joins as a listitem so ARIA stays valid), otherwise creates - * a plain meta row; one button per bubble. */ +/* The bottom-right "Save as doc" button — the house pattern through + * getActionsRow (phase 130 task 02: the role=list actions row joins + * the button as a listitem so ARIA stays valid); one button per + * bubble. */ function appendSaveAsDocButton(wrap, markdown) { if (!isAdmin || !docsRepoConfigured) return; // phase 59: admin + configured - const body = wrap.querySelector(".msg-body"); - if (!body) return; - let meta = body.querySelector(".msg-meta"); - if (!meta) { - meta = document.createElement("div"); - meta.className = "msg-meta"; - body.appendChild(meta); - } + const meta = getActionsRow(wrap); + if (!meta) return; if (meta.querySelector(".save-as-doc-btn")) return; // one per bubble const btn = document.createElement("button"); btn.type = "button"; - btn.className = "save-as-doc-btn"; // margin-inline-start: auto → bottom-right + btn.className = "save-as-doc-btn"; // bottom-right: the row's right-align (task 03) if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem"); btn.innerHTML = SAVE_AS_DOC_ICON + "Save as doc"; btn.addEventListener("click", () => saveAsDoc(btn)); @@ -1575,37 +1585,66 @@ async function readSSE(response, onEvent) { /* Source chips (mono, source/path) under a Brain bubble. Phase 26: * clicking a chip opens the document in the same-page modal (no new * tab) — the /document.html href stays as the no-JS / context-menu - * escape hatch. */ + * escape hatch. + * + * Phase 130 (task 01, TODO L6): the block is two CLUSTERS, never one + * interleaved row — the chips row holds the NON-image refs only (a + * ref carrying `image_url` is an image document, phase 122, and gets + * NO chip: its figure IS the citation), and a second images row + * (`.msg-meta.msg-images`) holds one `.source-image` figure per image + * ref, in ref order. Each row is present only when its refs are + * non-empty (an image-only citation gets its images row + the actions + * row and no chips row; a text-only turn renders the pre-phase-130 + * single row). Row order in .msg-body: the answer figure (the caller + * appends it first, phase 124) → chips row → images row → the + * related row (the next caller, appendRelated) → the actions row + * (the action appenders after appendRelated — phase 130 task 02, + * the bubble's LAST row). */ function appendSources(wrap, sources) { if (!sources || !sources.length) return; const body = wrap.querySelector(".msg-body"); - const meta = document.createElement("div"); - meta.className = "msg-meta"; - meta.setAttribute("role", "list"); - meta.setAttribute("aria-label", "Sources"); - for (const s of sources) { - const label = `${s.source}/${s.path}`; - const chip = document.createElement("a"); - chip.className = "source-chip"; - chip.setAttribute("role", "listitem"); - chip.href = documentUrl(s.source, s.path, "/"); // back → the chat page - chip.addEventListener("click", (e) => { - e.preventDefault(); // no new tab (phase 26) — the modal takes over - e.stopPropagation(); - openDocumentModal(s.source, s.path, chip); - }); - chip.textContent = label; - chip.title = label; - meta.appendChild(chip); - // Phase 122 (task 05): a ref for an IMAGE document carries - // `image_url` (the frame's only new field — omitted on text refs): - // its chip gains the compact inline figure right after it. - if (s.image_url) appendSourceImageFigure(meta, s); + // The `image_url` presence is the existing image-doc signal + // (phase 122): image refs cite by figure, text refs by chip. + const imageRefs = sources.filter((s) => s.image_url); + const textRefs = sources.filter((s) => !s.image_url); + // Cluster 1 — the chips row (the pre-phase-130 row, text refs + // only), skipped entirely when there are no text refs. + if (textRefs.length) { + const meta = document.createElement("div"); + meta.className = "msg-meta"; + meta.setAttribute("role", "list"); + meta.setAttribute("aria-label", "Sources"); + for (const s of textRefs) { + const label = `${s.source}/${s.path}`; + const chip = document.createElement("a"); + chip.className = "source-chip"; + chip.setAttribute("role", "listitem"); + chip.href = documentUrl(s.source, s.path, "/"); // back → the chat page + chip.addEventListener("click", (e) => { + e.preventDefault(); // no new tab (phase 26) — the modal takes over + e.stopPropagation(); + openDocumentModal(s.source, s.path, chip); + }); + chip.textContent = label; + chip.title = label; + meta.appendChild(chip); + } + body.appendChild(meta); + // Accessible full path whenever the pill visually truncates + // (the chips row only — the images row carries no chips). + for (const chip of meta.children) { + if (chip.scrollWidth > chip.clientWidth) chip.setAttribute("aria-label", chip.title); + } } - body.appendChild(meta); - // Accessible full path whenever the pill visually truncates. - for (const chip of meta.children) { - if (chip.scrollWidth > chip.clientWidth) chip.setAttribute("aria-label", chip.title); + // Cluster 2 — the images row: one figure per image ref, in ref + // order, no chips (absent when no image ref exists). + if (imageRefs.length) { + const images = document.createElement("div"); + images.className = "msg-meta msg-images"; + images.setAttribute("role", "list"); + images.setAttribute("aria-label", "Source images"); + for (const s of imageRefs) appendSourceImageFigure(images, s); + body.appendChild(images); } } @@ -1617,12 +1656,14 @@ function appendSources(wrap, sources) { * like the chips — the same documentUrl href (the /document.html escape * hatch) and the same left-click → same-page modal (phase 26). The * labeled row ("Nearby docs, in case:") joins the bubble's .msg-meta - * family and stacks below the citation row in the .msg-body flex gap. - * app.js appends it LAST among the meta rows — after appendTuneButton / - * appendSaveAsDocButton / appendRetryButton claimed the FIRST - * .msg-meta row — so a deflected turn (no citation row) never lets a - * meta action join this row. Empty/absent input → no DOM at all - * (pre-phase saved chats carry no `related` — the row is simply absent). + * family and stacks below the citation rows in the .msg-body flex gap. + * app.js appends it BELOW the citation clusters and ABOVE the actions + * row (phase 130 task 02 — the .msg-actions row is the bubble's LAST + * row, and the getActionsRow scoping keeps the buttons out of this row + * either way: a deflected turn with related docs gets its OWN actions + * row, never a button in the related row). Empty/absent input → no + * DOM at all (pre-phase saved chats carry no `related` — the row is + * simply absent). */ function appendRelated(wrap, related) { if (!related || !related.length) return; @@ -1669,9 +1710,12 @@ function appendRelated(wrap, related) { * fetches the content endpoint the chip's modal already uses (the * (source, path) pair is the same lookup key) and swaps the summary * in; until it settles — and when it fails — the title stands in (a - * caption is ALWAYS visible). A FAILED IMAGE LOAD collapses the figure - * to the plain chip (the figure is removed — never a broken-image - * icon: the row's bytes route 404s when the copy was lost). */ + * caption is ALWAYS visible). A FAILED IMAGE LOAD becomes the IN-PLACE + * CHIP FALLBACK (phase 130 task 01, ASSUMPTION A3): the figure is + * replaced, in place, with a plain `.source-chip` anchor for the same + * document — the citation survives a lost byte copy, never a + * broken-image icon (the row's bytes route 404s when the copy was + * lost). */ function appendSourceImageFigure(meta, s) { const label = `${s.source}/${s.path}`; const fig = document.createElement("a"); @@ -1690,9 +1734,25 @@ function appendSourceImageFigure(meta, s) { img.src = s.image_url; img.alt = s.title || label; // the summary arrives via the fetch below img.addEventListener("error", () => { - // The plain chip stays (it is the collapse target) — the figure, - // with its not-yet-resolved alt, goes. - fig.remove(); + // Phase 130 (task 01, ASSUMPTION A3): the IN-PLACE CHIP FALLBACK — + // there is no chip for image docs anymore (the figure is the + // citation), so the figure itself becomes one: replaced, in place, + // with a plain `.source-chip` anchor for the same document (the + // same documentUrl href, the same left-click → modal, the label as + // text) — the citation survives a lost byte copy, never a + // broken-image icon. + const chip = document.createElement("a"); + chip.className = "source-chip"; + chip.setAttribute("role", "listitem"); + chip.href = documentUrl(s.source, s.path, "/"); // back → the chat page + chip.addEventListener("click", (e) => { + e.preventDefault(); // no new tab (phase 26) — the modal takes over + e.stopPropagation(); + openDocumentModal(s.source, s.path, chip); + }); + chip.textContent = label; + chip.title = label; + fig.replaceWith(chip); }); const caption = document.createElement("span"); caption.className = "source-image-caption"; @@ -1701,7 +1761,7 @@ function appendSourceImageFigure(meta, s) { meta.appendChild(fig); // …and the document's summary once the content fetch settles. fetchContentSummary(s.source, s.path).then((summary) => { - if (!fig.isConnected) return; // collapsed (img error) or bubble cleared + if (!fig.isConnected) return; // replaced by the chip fallback (img error) or bubble cleared if (summary && summary.trim() !== "") { img.alt = summary; caption.textContent = summary; @@ -1729,10 +1789,11 @@ function fetchContentSummary(source, path) { /* Phase 124 (task 06, TODO L3): the LLM's ANSWER IMAGE — the brain * answer's first-class figure (the `done` frame's `answer_image` ref, * task 04). A4: it sits in the .msg-body BELOW the answer text and - * ABOVE the source-chip row — the callers append it right BEFORE - * appendSources creates the chip row, so it lands in between. The - * text bubble is untouched: the figure is ADDITIVE ("summaries - * should still be provided"). + * ABOVE the source clusters — the callers append it right BEFORE + * appendSources builds the rows (phase 130 task 01: the chips row, + * then the images row), so it lands in between. The text bubble is + * untouched: the figure is ADDITIVE ("summaries should still be + * provided"). * * The visual sibling of appendSourceImageFigure (the compact chip * figure) but a different surface: the answer figure is the answer's @@ -1983,10 +2044,20 @@ function renderStoredMessage(m) { } // Phase 124 (task 06, TODO L3): the answer image restores with the // bubble — the SAME helper + the SAME A4 position as the live done - // path (below the text, above the chip row). A record without the - // key renders byte-identically (pre-phase chats restore unchanged). + // path (below the text, above the source clusters — phase 130: + // the chips row, then the images row). A record without the key + // renders byte-identically (pre-phase chats restore unchanged). if (m.answer_image) appendAnswerImage(wrap, m.answer_image); appendSources(wrap, m.sources); + // Phase 113 (task 02) / phase 130 (task 02): the related tier + // restores with the bubble — BELOW the citation clusters and ABOVE + // the actions row: the action appenders below (Tune / Save-as-doc / + // notes, plus the restore loop's after-the-loop markLastRetryable) + // get-or-create the .msg-actions row as the bubble's LAST row, + // exactly like the live done path. Pre-phase records carry no + // `related` → appendRelated no-ops and the row is simply absent + // (graceful). + appendRelated(wrap, m.related); // Phase 120: a failed turn (m.failed) is a note, not an answer — no // Tune (the "note, not an answer" scope the m.stopped exclusion // below shares; the live failure paths add no Tune either). Stopped @@ -2009,11 +2080,6 @@ function renderStoredMessage(m) { // precedes the failed record, unchanged; a mid-conversation failed // bubble gets no button — the phase-49 last-bubble-only rule). if (m.failed && m.error) appendFailedNote(wrap, m.error); - // Phase 113 (task 02): the related tier restores with the bubble - // (LAST — after the meta-row claimers, exactly like the live done - // path). Pre-phase records carry no `related` → appendRelated - // no-ops and the row is simply absent (graceful). - appendRelated(wrap, m.related); lastBrainWrap = wrap; // phase 49: the LAST restored brain bubble wins } @@ -3067,8 +3133,9 @@ async function runTurn(text, { reask = false, image = null } = {}) { } // Phase 124 (task 06, TODO L3): the LLM's answer image — the // answer's first-class figure (A4): appended to .msg-body - // right BEFORE the chip row, so it lands below the answer - // text and above the sources. The text is untouched (the + // right BEFORE the source clusters (phase 130: the chips row, + // then the images row), so it lands below the answer text + // and above the sources. The text is untouched (the // figure is additive — "summaries should still be provided"); // a frame without it renders byte-identically (no figure DOM). if (ev.answer_image) appendAnswerImage(wrap, ev.answer_image); @@ -3077,20 +3144,17 @@ async function runTurn(text, { reask = false, image = null } = {}) { // surface (the chips above); the related tier (scored docs // under the usefulness bar — and the weak hits of a DEFLECTED // turn, whose `ev.sources` is empty → zero chips) renders as - // the de-emphasized labeled row, appended LAST among the meta - // rows below (after the appendTuneButton/SaveAsDoc/Retry - // claimers took the first .msg-meta row, so an action never - // joins the related row — a deflected turn with related docs - // still gets its own meta row for the buttons). + // the de-emphasized labeled row BELOW the citation clusters + // and ABOVE the actions row (phase 130 task 02 — the + // .msg-actions row is the bubble's LAST row, so the action + // appenders after this get-or-create it; the getActionsRow + // scoping keeps the buttons out of the related row either way + // — a deflected turn with related docs still gets its own + // actions row). // Thinking-without-answer (reasoning can exhaust max_tokens): the // bubble gets the empty-answer fallback — what the user saw is // what gets persisted. const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : ""); - appendTuneButton(wrap); // every completed brain bubble is tunable - // Phase 59: the RAW persisted markdown (never the rendered - // HTML) — exactly the string rememberBrainTurn stores below, - // so a reload (the restore path) offers the identical draft. - appendSaveAsDocButton(wrap, finalText || acc || "…"); if (!acc && sawThinking) { wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText); } @@ -3120,8 +3184,17 @@ async function runTurn(text, { reask = false, image = null } = {}) { suggestions: ev.suggestions, }, leavePartialIndex); lastBrainWrap = wrap; // this bubble is now the last brain answer + // Phase 130 (task 02): the row order is the DOM order — the + // related row above, then the actions row LAST (Tune → Retry + // → Save-as-doc in insertion order, the bubble's bottom-right + // cluster). + appendRelated(wrap, ev.related); // phase 113 — above the actions row + appendTuneButton(wrap); // every completed brain bubble is tunable markLastRetryable(); // phase 49: the Retry button is last-bubble-only - appendRelated(wrap, ev.related); // phase 113 (task 02) — LAST meta row + // Phase 59: the RAW persisted markdown (never the rendered + // HTML) — exactly the string rememberBrainTurn stores above, + // so a reload (the restore path) offers the identical draft. + appendSaveAsDocButton(wrap, finalText || acc || "…"); } else if (ev.type === "tool_result") { // Phase 95 (A15 extension, task 02): the truncation the LLM is // told about is told to the USER. One frame per truncated read, @@ -3192,7 +3265,6 @@ async function runTurn(text, { reask = false, image = null } = {}) { const nothing = "The model answered with nothing."; const fwrap = addMessage("brain", fallback); appendTuneButton(fwrap); - appendSaveAsDocButton(fwrap, fallback); // phase 59: parity with the done path appendFailedNote(fwrap, nothing); // Phase 73: correlated like the other settles — unreachable when a // pagehide partial exists (that needs acc, which means a wrap), but @@ -3203,6 +3275,7 @@ async function runTurn(text, { reask = false, image = null } = {}) { }, leavePartialIndex); // persist what the user actually saw lastBrainWrap = fwrap; markLastRetryable(); // phase 49: the fallback bubble is retryable too + appendSaveAsDocButton(fwrap, fallback); // phase 59: parity with the done path } } catch (err) { if (aborted) { diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index cf49e5c..46779e7 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -752,6 +752,29 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } gap: 0.35rem; align-items: center; } +/* Phase 130 (task 03, TODO L6): the ACTIONS cluster — the bubble's + LAST .msg-meta row (Tune / Retry / Save-as-doc + the Stopped/Failed + notes — phase 130 task 02's getActionsRow). The row's justify does + the TODO's "bottom right" (the bubble's bottom row, right edge); + the buttons are plain flex items of the row — the per-button auto + margins (the phase-49/59 .tune-btn/.retry-btn/.save-as-doc-btn + margin mechanisms) are gone, so chips and figures can never push + the cluster around. DOM order inside the row = insertion order + (Tune, Retry, Save-as-doc per their gates). */ +.msg-meta.msg-actions { + justify-content: flex-end; +} +/* Phase 130 (task 03, TODO L6): the source-IMAGES cluster — the + second .msg-meta row appendSources builds (phase 130 task 01): + one .source-image figure per image ref, no chips (the figure IS + the citation for image docs). The explicit rule PINS the chip row's + rhythm — wrap + the base .msg-meta 0.35rem gap + center align — so + a future base-rule change cannot drift the two clusters apart. */ +.msg-meta.msg-images { + flex-wrap: wrap; + gap: 0.35rem; + align-items: center; +} .source-chip { display: inline-flex; align-items: center; @@ -918,8 +941,10 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und padding-inline: 0.25rem; } /* ---------- Steering notes (phase 15) ---------- */ -/* "Tune" button in the meta row of every completed brain bubble: ghost - pill, ≥44px, right-aligned after the source chips. ink-soft on surface +/* "Tune" button in the completed brain bubble's actions row + (.msg-meta.msg-actions — phase 130: the ROW's right-align is the + cluster's "bottom right"; the button is a plain flex item, no + per-button auto margin). Ghost pill, ≥44px. ink-soft on surface ≈6.9:1; hover pair brand-ink/brand-soft ≈6.9:1. */ .tune-btn { display: inline-flex; @@ -927,7 +952,6 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und justify-content: center; gap: 0.35rem; min-height: 44px; - margin-left: auto; padding: 0.35rem 0.8rem; border-radius: 999px; border: 1px solid var(--line); @@ -943,11 +967,13 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und .tune-btn:hover { background: var(--brand-soft); color: var(--brand-ink); } /* Phase 49 (owner-locked 2026-08-29, TODO.md L4): the "Retry" button — - the redo-in-place action in the meta row of the LAST brain bubble. - The exact visual family of .tune-btn (same pill size/spacing, the - global :focus-visible ring, >=44px via min-height) so the two meta - actions read as a pair — but neutral: ink-soft -> ink on hover (Tune - keeps the brand pair). The redo glyph rides currentColor. Contrast: + the redo-in-place action in the actions row (.msg-meta.msg-actions — + phase 130) of the LAST brain bubble. The exact visual family of + .tune-btn (same pill size/spacing, the global :focus-visible ring, + >=44px via min-height) so the meta actions read as a set — but + neutral: ink-soft -> ink on hover (Tune keeps the brand pair). The + redo glyph rides currentColor. No per-button auto margin (phase 130 + — the row's justify right-aligns the cluster). Contrast: ink-soft on --bg ~8.6:1, ink ~15:1, hover ink on --brand-soft ~15.7:1 — all AA. */ .retry-btn { @@ -956,7 +982,6 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und justify-content: center; gap: 0.35rem; min-height: 44px; - margin-left: auto; padding: 0.35rem 0.8rem; border-radius: 999px; border: 1px solid var(--line); @@ -978,9 +1003,10 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und (same pill size/spacing, the global :focus-visible ring, >=44px via min-height) so the meta actions read as one set — the brand hover pair like Tune (a docs action), the file glyph rides currentColor. - margin-inline-start: auto pushes it to the row's RIGHT edge (the - TODO's "bottom right"; markLastRetryable keeps it rightmost when - the last bubble also carries the Retry button). Contrast: + No per-button auto margin (phase 130 — the row's .msg-actions + justify right-aligns the WHOLE cluster to the row's RIGHT edge, the + TODO's "bottom right"; the button appends LAST in the row's DOM + order per its gate, so it stays the rightmost action). Contrast: ink-soft on --bg ~8.6:1, hover brand-ink on --brand-soft — AA, same as the family. */ .save-as-doc-btn { @@ -989,7 +1015,6 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und justify-content: center; gap: 0.35rem; min-height: 44px; - margin-inline-start: auto; padding: 0.35rem 0.8rem; border-radius: 999px; border: 1px solid var(--line); diff --git a/tests/e2e/test_answer_images.py b/tests/e2e/test_answer_images.py index f97e84a..7f00369 100644 --- a/tests/e2e/test_answer_images.py +++ b/tests/e2e/test_answer_images.py @@ -19,16 +19,18 @@ mock LLM and the shared e2e Postgres (truncate/re-import per app): turn, the shape that makes the chip assertion meaningful). The brain bubble shows the ANSWER FIGURE (``.answer-image`` — ```` from the bytes route, ``loading="lazy"``, below the text and above - the chip row per A4, alt/caption settling to the summary), the - answer TEXT is still streamed in full (the tail echo of the + the source clusters per A4, alt/caption settling to the summary), + the answer TEXT is still streamed in full (the tail echo of the ```` seed proves the description was in the prompt), the - source-chip row carries the image doc's chip + its phase-122 - figure (the AUTO-CITE proof — the mock made zero ``read`` calls, - asserted on the mock's request capture), and the "Nearby docs" row - does not repeat the doc. The wire pin: the first model request - offered FOUR tools (the three base + ``show_image``) and the turn - executed exactly one ``show_image`` — the saved brain record - persists the ``answer_image`` ref. + sources block carries the image doc's citation as the compact + figure in the images row (``.msg-meta.msg-images`` — phase 130 + task 01: NO chip for an image doc, the figure IS the citation; + the AUTO-CITE proof — the mock made zero ``read`` calls, asserted + on the mock's request capture), and the "Nearby docs" row does not + repeat the doc. The wire pin: the first model request offered + FOUR tools (the three base + ``show_image``) and the turn executed + exactly one ``show_image`` — the saved brain record persists the + ``answer_image`` ref. - **Restore + share** — a second feature turn, then a page RELOAD: the restored brain bubble shows the figure again (the ``answer_image`` persistence through the ``bor.chat.v1`` restore @@ -36,11 +38,12 @@ mock LLM and the shared e2e Postgres (truncate/re-import per app): fresh anonymous context) renders the figure on the shared page. - **Bug-fix-only flow** — the ``which diagram is in your notes`` trigger (the mock answers straight from the seed with ZERO tool - calls — no ``show_image``, no ``read``): the source-chip row STILL - carries the image doc's chip + figure (the bug fix on the pure - zero-read path — the assertion the owner's report describes), the - answer text quotes the seeded description's tail, and NO answer - figure renders (the model never called the tool). + calls — no ``show_image``, no ``read``): the sources block STILL + carries the image doc's citation figure (no chip — phase 130; + the bug fix on the pure zero-read path — the assertion the owner's + report describes), the answer text quotes the seeded + description's tail, and NO answer figure renders (the model never + called the tool). * **App B (default off)** — ``BOR_IMAGES=true``, ``BOR_ANSWER_IMAGES`` pinned to the code default ``false`` (EXPLICITLY — process env ranks above an operator's local gitignored ``.env``, so the default-off @@ -51,9 +54,10 @@ mock LLM and the shared e2e Postgres (truncate/re-import per app): ``show_image`` among the offered tools (the request capture carries exactly the THREE base tools), the brain bubble has NO answer figure, and the saved brain record carries NO ``answer_image`` key - (the byte-identical-off contract). The bug-fix trigger STILL chips - the image doc (the chips fix is NOT gated by ``BOR_ANSWER_IMAGES`` — - it belongs to the ``BOR_IMAGES``-indexed docs, per the phase design). + (the byte-identical-off contract). The bug-fix trigger STILL cites + the image doc (the figure, no chip — phase 130; the auto-cite fix is + NOT gated by ``BOR_ANSWER_IMAGES`` — it belongs to the + ``BOR_IMAGES``-indexed docs, per the phase design). DB isolation: the suites share one Postgres; ``saved_chats`` survives the truncation (the house pattern), so every question text here is @@ -593,15 +597,18 @@ def _assert_answer_figure( def _assert_image_chip( page: Page, seeded: dict[str, str], *, last_brain: str = ".msg.brain" ) -> None: - """The citation chip the AUTO-CITE earns (phase 124 task 05): the - image doc's text chip + its phase-122 compact figure (```` - from the bytes route, alt settling to the summary — the admin - surface; the shared page's chip is plain text and asserted - separately).""" + """The AUTO-CITE (phase 124 task 05) in the phase-130 cluster + shape (task 01, TODO L6): an image doc gets NO chip — its figure + IS the citation — the compact figure (```` from the bytes + route, alt settling to the summary — the admin surface; the + shared page's plain-text chip is asserted separately) sits in the + images row (``.msg-meta.msg-images``).""" chip = page.locator(f"{last_brain} .source-chip") - expect(chip).to_have_count(1) - expect(chip).to_have_text(f"{SOURCE_NAME}/{DOC_PATH}") - fig = page.locator(f"{last_brain} .source-image") + expect(chip).to_have_count(0) + row = page.locator(f"{last_brain} .msg-meta.msg-images") + expect(row).to_have_count(1) + expect(row).to_have_attribute("aria-label", "Source images") + fig = row.locator(".source-image") expect(fig).to_have_count(1) img = fig.locator(".source-image-img") expect(img).to_be_visible(timeout=15_000) @@ -612,7 +619,7 @@ def _assert_image_chip( def _body_child_classes(page: Page) -> list[str]: """The LAST brain bubble's ``.msg-body`` direct children's class names, in DOM order — the A4 position pin (bubble < answer-image - < msg-meta).""" + < the first .msg-meta-family source row).""" return page.evaluate( """() => { const body = [...document.querySelectorAll(".msg.brain")].pop() @@ -635,15 +642,18 @@ def test_feature_flow_answer_figure_chips_and_zero_reads( a ``show_image`` call (NO ``read`` — the zero-read turn). Asserts: (1) the brain bubble's ``.msg-body`` carries ``.answer-image`` with an ```` from the bytes route (200, image Content-Type) and - ``loading="lazy"``; (2) the figure sits BEFORE the ``.msg-meta`` - source-chip row (A4); (3) the answer TEXT is non-empty and quotes - the seed's tail (the "summaries still provided" contract + the - echo proving the seed was in the prompt); (4) the source-chip row - carries the image doc's chip + its figure (the AUTO-CITE proof — - the mock made ZERO ``read`` calls: only ``show_image`` — asserted - on the wire); (5) the "Nearby docs" row does NOT repeat the doc. - Plus the persistence pin: the saved brain record carries the - ``answer_image`` ref (the task-04 frame field, round-tripped).""" + ``loading="lazy"``; (2) the figure sits BELOW the answer text and + ABOVE the source clusters (A4 — the image-only KB renders the + images row, ``.msg-meta.msg-images`` — phase 130 task 01: no chip + row); (3) the answer TEXT is non-empty and quotes the seed's tail + (the "summaries still provided" contract + the echo proving the + seed was in the prompt); (4) the images row carries the image + doc's figure — no chip (phase 130: the figure IS the citation) — + the AUTO-CITE proof (the mock made ZERO ``read`` calls: only + ``show_image`` — asserted on the wire); (5) the "Nearby docs" row + does NOT repeat the doc. Plus the persistence pin: the saved + brain record carries the ``answer_image`` ref (the task-04 frame + field, round-tripped).""" login(page, app_url, next="/") expect(page.locator("#kb-banner")).to_be_hidden() @@ -664,20 +674,25 @@ def test_feature_flow_answer_figure_chips_and_zero_reads( # summary (the fetchContentSummary pattern settles both). _assert_answer_figure(page, app_url, seeded, caption=seeded["description"]) - # (2) A4 position: below the answer text, above the chip row. + # (2) A4 position: below the answer text, above the source + # clusters — the image-only KB renders the images row + # (.msg-meta.msg-images, phase 130 task 01: no chip row). classes = _body_child_classes(page) assert "bubble" in classes, f"no answer bubble in .msg-body: {classes}" assert "answer-image" in classes, f"no .answer-image in .msg-body: {classes}" - assert "msg-meta" in classes, f"no chip row in .msg-body: {classes}" + meta_idx = next( + (i for i, c in enumerate(classes) if c.startswith("msg-meta")), None + ) + assert meta_idx is not None, f"no source row in .msg-body: {classes}" assert ( classes.index("bubble") < classes.index("answer-image") - < classes.index("msg-meta") - ), f"A4 order violated (bubble < answer-image < msg-meta): {classes}" + < meta_idx + ), f"A4 order violated (bubble < answer-image < source row): {classes}" - # (4) The chip row carries the image doc's chip + its figure — - # the AUTO-CITE (the model never read it; the seed IS its - # entire content, so suggested == read for it). + # (4) The images row carries the image doc's figure (no chip — + # phase 130) — the AUTO-CITE (the model never read it; the + # seed IS its entire content, so suggested == read for it). _assert_image_chip(page, seeded) # (5) The "Nearby docs" row does NOT repeat the image doc — the @@ -817,8 +832,8 @@ def test_reload_restores_figure_and_shared_page_shows_it( # --------------------------------------------------------------------------- -# 3. App A — the bug-fix-only flow: ZERO tool calls, the chip STILL -# appears (the pure zero-read path — the owner's report) +# 3. App A — the bug-fix-only flow: ZERO tool calls, the citation +# STILL renders (the pure zero-read path — the owner's report) # --------------------------------------------------------------------------- @@ -828,11 +843,12 @@ def test_bugfix_only_flow_zero_tool_calls_still_chips( """The ``which diagram is in your notes`` trigger: the mock answers straight from the seed with ZERO tool calls (no ``show_image`` — the clause's flow is not triggered — and no - ``read``): the source-chip row STILL carries the image doc's chip - + figure (the bug fix on the pure zero-read path — the assertion - the owner's report describes), the answer text quotes the seeded - description's tail (the description WAS in the prompt), and NO - answer figure renders (the model never called the tool).""" + ``read``): the sources block STILL carries the image doc's + citation figure (no chip — phase 130; the bug fix on the pure + zero-read path — the assertion the owner's report describes), the + answer text quotes the seeded description's tail (the description + WAS in the prompt), and NO answer figure renders (the model never + called the tool).""" login(page, app_url, next="/") expect(page.locator("#kb-banner")).to_be_hidden() @@ -860,8 +876,9 @@ def test_bugfix_only_flow_zero_tool_calls_still_chips( expect(page.locator(".msg.brain .tool-calls-disclosure")).to_have_count(0) # NO answer figure (the model never called show_image) — but the - # chip row STILL carries the image doc's chip + figure (the auto- - # cite: suggested == read for an image doc, no read performed). + # sources block STILL carries the image doc's citation figure + # (no chip — phase 130; the auto-cite: suggested == read for an + # image doc, no read performed). expect(page.locator(".msg.brain .answer-image")).to_have_count(0) _assert_image_chip(page, seeded) @@ -936,8 +953,8 @@ def test_toggle_off_flow_no_tool_no_answer_image_key( # --------------------------------------------------------------------------- -# 5. App B (default off) — the bug-fix trigger STILL chips the image -# doc (the chips fix belongs to BOR_IMAGES, not BOR_ANSWER_IMAGES) +# 5. App B (default off) — the bug-fix trigger STILL cites the image +# doc (the auto-cite fix belongs to BOR_IMAGES, not BOR_ANSWER_IMAGES) # --------------------------------------------------------------------------- @@ -946,10 +963,11 @@ def test_toggle_off_bugfix_trigger_still_chips( ) -> None: """The bug-fix trigger on the default-OFF app (the clause is absent — the trigger does NOT require it): ZERO tool calls, the - answer quotes the seeded description's tail, and the source-chip - row STILL carries the image doc's chip + figure — the chips fix - is NOT gated by ``BOR_ANSWER_IMAGES`` (it belongs to the - ``BOR_IMAGES``-indexed docs, per the phase design).""" + answer quotes the seeded description's tail, and the sources + block STILL carries the image doc's citation figure (no chip — + phase 130) — the auto-cite fix is NOT gated by + ``BOR_ANSWER_IMAGES`` (it belongs to the ``BOR_IMAGES``-indexed + docs, per the phase design).""" login(page, default_app_url, next="/") expect(page.locator("#kb-banner")).to_be_hidden() diff --git a/tests/e2e/test_chat_answer_clusters.py b/tests/e2e/test_chat_answer_clusters.py new file mode 100644 index 0000000..a8b4089 --- /dev/null +++ b/tests/e2e/test_chat_answer_clusters.py @@ -0,0 +1,939 @@ +"""Phase 130 task 04 E2E (Playwright, mock-only): the chat answer renders +as organized clusters — chips row, images row, bottom-right actions row. + +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_chat_answer_clusters.py -v --no-cov + +MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the story's gate +is the deterministic cluster DOM of the rendered answer, which needs the +``done`` frame to cite ONE text doc AND ONE image doc in the same +answer. That mixed citation is driven by the house scripted turn in +``tests/e2e/mock_llm.py`` (``DRILL_TRIGGER``: the user message contains +``drill down the tree`` — the question carries its own tool call after +the colon, ``drill down the tree: read e2e-clusters/note.md`` — **and** +the system prompt carries the ```` section of the HIGH prompt). +The scripted ``read`` cites the text doc (the phase-119 read-docs-only +rule) while the image doc joins the citation tier auto-cited (its +suggested-tier seed block IS its entire content — phase 124): the +``done`` frame's ``sources`` carries exactly the two refs — the text +ref WITHOUT ``image_url`` (the phase-122 omission rule) and the image +ref WITH it — the shape phase 130 renders as two clusters (chips row +text-only, images row figures-only, NO chip for the image doc) + the +actions row last. + +KB fixture — ONE local source (a ``tmp_path_factory`` dir, registered +through the authenticated API + the real in-process ``POST /api/sync`` +— the ``test_local_directory_sources.py`` / ``test_ls_tree_drilldown. +py`` pattern): + +* ``e2e-clusters/note.md`` — a markdown doc whose body carries the + trigger phrase's words + the diagram vocabulary: the mock's + token-overlap cosine against the scripted question clears the + mock-calibrated 0.30 gate (the turn grounds, the ```` section + is present — the flow's precondition); +* ``e2e-clusters/pic.png`` — a standalone 1×1 PNG: the module app runs + ``BOR_IMAGES=true`` (the phase-122 pattern — the mock's + ``IMAGE_DESCRIPTION_MODE`` writes the byte-stable + ``IMAGE_DESCRIPTION_ANSWER`` as the doc's content + summary). + +The module app ALSO pins ``BOR_DOCS_REPO`` to a non-empty scratch path +(the ``docs_repo_configured`` gate is ``bool(docs_repo.strip())`` — a +configured repo reveals the Save-as-doc button for the admin; the push +endpoint is never exercised — the button is never clicked) and +``BOR_ANSWER_IMAGES=false`` explicitly (the leak-guard convention: the +owner's local gitignored ``.env`` cannot leak the answer-image toggle +into the premise — the scripted turn makes NO ``show_image`` call, so +the bubble carries no ``.answer-image``). + +Test → observable mapping (Playwright Mapping Rule): +1. ``test_answer_clusters_render_in_order`` — the LIVE turn: the wire + carries the scripted ``read`` once, and the ``done`` frame cites + BOTH docs — the read text doc first (read order), then the + auto-cited image ref (``image_url`` present; the text ref's key is + ABSENT); the last brain bubble's ``.msg-body`` direct children are + exactly ``[tool-calls-disclosure, bubble, msg-meta, msg-meta + msg-images, msg-meta msg-actions]`` — the chips row (EXACTLY the + text chip — NO chip for the image doc, phase 130 task 01) before + the images row (the figure, ```` loaded from the bytes route, + alt + caption settling to the canned description), no related row + (the 2-doc KB has no related tier), and the actions row LAST + (the "bottom" of the TODO's "bottom right") holding ALL THREE gated + buttons (admin + configured docs repo — the phase-16/59 gates). + Geometry (bounding boxes, not pixels): the actions row's box is + BELOW the images row's, and the rightmost button's right edge sits + at the row's right edge minus the row's 0.25rem padding (the + per-button auto margins are gone — the row's + ``justify-content: flex-end`` does the right-align, phase 130 + task 03). +2. ``test_image_without_chip_keeps_its_citation_via_figure`` — the + same bubble: the ONLY ``.source-chip`` is the text doc's (the + owner's exact ask — no chip whose label is the image's + ``source/path``), while the image doc is fully cited: the figure's + ``href`` is its ``documentUrl`` and a left-click opens the same- + page modal (the phase-26 affordance — the modal shows the image + + the description). The text chip keeps its own phase-26 affordance + (left-click → the same modal) — the no-behavior-change half. +3. ``test_restore_renders_the_same_clusters`` — the turn auto-saves + (phase 66); a page reload restores it through the phase-14 restore + path (the same appenders): the restored last brain bubble carries + the IDENTICAL ``.msg-body`` child sequence (chips row text-only, + images row, actions row LAST with the three gated buttons) and the + same geometry — the DOM order assertion pinned for BOTH the live + and the restored bubble. +4. ``test_non_admin_sees_retry_only_actions`` — a FRESH non-admin + browser context (no admin cookie — the real in-app token gate, + ``login_with_token``, the phase-79 pattern) on a FRESH chat: the + same scripted turn, and the ``.msg-actions`` row holds ONLY the + ``.retry-btn`` (Tune admin-gated phase 16, Save-as-doc admin- + gated phase 59 — the gates unchanged) while the cluster rows + render identically (the render is not identity-gated). + +DB isolation: the suites share one Postgres. ``saved_chats`` survives +the truncation (the house pattern) — every question text here is +UNIQUE (its auto-title therefore is) and the module boundary cleans +this suite's rows by title. The non-admin turn's auto-save 403s +(``/api/chats`` is admin-only — the quiet A2 status note, no row) and +its token is revoked at the end of the test. +""" +from __future__ import annotations + +import base64 +import json +import os +import subprocess +import sys +import time +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path +from typing import Any + +import httpx +import pytest +from playwright.sync_api import Browser, Locator, Page, expect +from sqlalchemy import select, text + +from app.config import Settings +from app.db import SessionLocal +from app.models import Document +from e2e.auth_helpers import login, login_with_token +from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http +from e2e.mock_llm import IMAGE_DESCRIPTION_ANSWER + +REPO = Path(__file__).resolve().parents[2] + +# Phase 79 (task 04, full inventory): the conftest session app owns its +# port in a combined run — this module app binds its own port instead +# (a same-port second uvicorn dies on bind and would drive the wrong +# server). Env-overridable. +APP_PORT = int(os.environ.get("E2E_APP_PORT_CLUSTERS", "8170")) +APP_URL = f"http://127.0.0.1:{APP_PORT}" + +SETTLE_TIMEOUT_MS = 30_000 +FIGURE_TIMEOUT_MS = 15_000 + +# -------------------------------------------------------------------------- +# The fixture (one local source: ONE text doc + ONE image doc) +# -------------------------------------------------------------------------- + +SOURCE_NAME = "e2e-clusters" # the directory basename = the source name +TEXT_PATH = "note.md" +IMG_PATH = "pic.png" +TEXT_SP = f"{SOURCE_NAME}/{TEXT_PATH}" # the chip's label (source/path) +IMG_SP = f"{SOURCE_NAME}/{IMG_PATH}" +TEXT_TITLE = "Homelab Network Note" # the first H1 (extract_title) +IMG_TITLE = "pic" # the image doc's title = the file stem (phase 122) +#: The chip/figure ``href`` (the phase-26 same-page-modal affordance — +#: ``documentUrl(source, path, "/")`` — the /document.html escape hatch). +TEXT_DOC_URL = f"/document.html?source={SOURCE_NAME}&path={TEXT_PATH}&back=%2F" +IMG_DOC_URL = f"/document.html?source={SOURCE_NAME}&path={IMG_PATH}&back=%2F" + +#: The text doc's body carries the trigger phrase's words + the diagram +#: vocabulary, so the scripted questions (which repeat them) clear the +#: mock's token-overlap cosine gate deterministically (~0.5 vs the +#: mock-calibrated 0.30). +TEXT_CONTENT = ( + "# Homelab Network Note\n" + "\n" + "The drill down the tree fixture note for the e2e clusters suite: this\n" + "homelab note describes the network diagram — the core router, the core\n" + "switch, and the three VLAN subnets the homelab network diagram shows.\n" +) + +# The scripted turn's answer (the phase-37 single-read shape): the mock +# quotes the read document's FIRST 80 chars — the H1 line (renders as a +# heading) + 56 body chars (the assertable prefix below, one text node). +READ_ANSWER_PREFIX = f"Read {TEXT_SP}." +READ_BODY_QUOTE = "The drill down the tree fixture note for the e2e cluster" + +# A real 1×1 transparent PNG (the unit/integration suites' fixture — +# the pipeline is content-agnostic, the well-formed bytes keep the +# upload + serve + render path honest). +PNG_1X1 = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ" + "AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII=" +) + +# -------------------------------------------------------------------------- +# The scripted questions (the mock's ``DRILL_TRIGGER`` — the regex +# ``drill down the tree:\s*read\s+[a-z0-9_./-]+`` parses the read +# target from the RAW message, so the trigger line stays lowercase and +# the path stays in its character set). Each tail is unique — the +# auto-titles are unique on the shared e2e Postgres (the row-lookup +# convention of test_answer_images). +# -------------------------------------------------------------------------- + +Q_ORDER = ( + "drill down the tree: read e2e-clusters/note.md — " + "what does the homelab note say about the diagram?" +) +Q_FIGURE = ( + "drill down the tree: read e2e-clusters/note.md — " + "which diagram does the note describe?" +) +Q_RESTORE = ( + "drill down the tree: read e2e-clusters/note.md — " + "summarize the homelab network note (restore)" +) +Q_NONADMIN = ( + "drill down the tree: read e2e-clusters/note.md — " + "what is the note's diagram? (non-admin)" +) +assert all(len(q) <= 120 for q in (Q_ORDER, Q_FIGURE, Q_RESTORE, Q_NONADMIN)), ( + "the auto-title cap is 120 chars — the questions must stay whole" +) + +#: The .msg-body child sequence of a completed scripted-read bubble +#: (phase 130's fix, DOM-asserted): the tool record (the scripted read), +#: the answer bubble, the chips row (the bare .msg-meta), the images +#: row, the actions row LAST. The live and restore paths share it. +EXPECTED_CHILD_CLASSES = [ + "tool-calls-disclosure", + "bubble", + "msg-meta", + "msg-meta msg-images", + "msg-meta msg-actions", +] + + +# --------------------------------------------------------------------------- +# Module app: images ON + a configured docs repo (the Save-as-doc gate) +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def cluster_dir(tmp_path_factory: pytest.TempPathFactory) -> Path: + """The one-source fixture tree (see the module docstring): the app + server runs on the same host, so the paths are visible to it. The + directory NAME is the source name (``kind=local`` → the directory's + basename, phase 38).""" + scratch = tmp_path_factory.mktemp("bor_clusters") + src = scratch / SOURCE_NAME + src.mkdir() + (src / TEXT_PATH).write_text(TEXT_CONTENT, encoding="utf-8") + (src / IMG_PATH).write_bytes(PNG_1X1) + assert (src / TEXT_PATH).is_file() and (src / IMG_PATH).is_file() + return src + + +@pytest.fixture(scope="module") +def app_server(mock_llm: int, cluster_dir: Path) -> Iterator[str]: + """The real app under test — per-module app (the conftest pattern): + NO ``BOR_GIT_SOURCES`` (the env fallback is git-only — the source + here is a DB-registered local directory), ``BOR_IMAGES=true`` (the + PNG must become an image document), a configured ``BOR_DOCS_REPO`` + (the Save-as-doc reveal gate), and the leak-guarded code defaults. + The session app is never started in this isolated run, so no port + clash.""" + scratch = cluster_dir.parent # the tmp dir holding the source tree + env = dict(os.environ) + env.pop("DEBUGPY", None) + env["BOR_ENVIRONMENT"] = "e2e" + env["BOR_STATIC_DIR"] = str(REPO / "frontend") + env["BOR_LLM_BASE_URL"] = ( + "https://aipi.reeseapps.com/v1" + if USE_REAL_LLM + else f"http://127.0.0.1:{mock_llm}/v1" + ) + # Mock-calibrated gate (conftest pattern): the scripted questions + # ground on these values (see TEXT_CONTENT). + env["BOR_RELEVANCE_THRESHOLD"] = "0.30" + env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15" + env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15" + # Phase 67: instant retry waits + the code-default budget. + env["BOR_LLM_RETRY_DELAY"] = "0" + env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default) + env.setdefault( + "BOR_DATABASE_URL", + "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese", + ) + # Phase 16: admin auth must be set or create_app() refuses to boot. + env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD + env["BOR_SESSION_SECRET"] = SESSION_SECRET + # The repo's .env file carries the owner's BOR_GIT_SOURCES (the app + # reads it from cwd) — override it with an EMPTY value: the registry + # must hold EXACTLY this suite's local directory. + env["BOR_GIT_SOURCES"] = "" + # Leak guards (conftest pattern): an operator's local (gitignored) + # .env cannot leak corpus-specific settings into the app under test. + env["BOR_DOCS_REPO"] = str(scratch / "docs") # non-empty → configured + env["BOR_DOCS_WORK_DIR"] = str(scratch / "docs-work") + env["BOR_SUGGESTIONS"] = json.dumps( + Settings.model_fields["suggestions"].default + ) + env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default + env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default + # Phase 122: the PNG must become an image document; the image/upload + # homes are in the suite's scratch (never the owner's real + # ~/bor-sources). The answer-image toggle is pinned OFF explicitly — + # the scripted turn makes no show_image call, so no .answer-image. + env["BOR_IMAGES"] = "true" + env["BOR_ANSWER_IMAGES"] = "false" + env["BOR_UPLOAD_DIR"] = str(scratch / "uploads") + env["BOR_SOURCES_DIR"] = str(scratch / "checkouts") + env["BOR_IMAGE_DIR"] = str(scratch / "images") + proc = subprocess.Popen( + [sys.executable, "-m", "uvicorn", "app.main:app", + "--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"], + cwd=REPO, + env=env, + ) + try: + _wait_http(f"{APP_URL}/api/health") + yield APP_URL + finally: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + + +@pytest.fixture(scope="module") +def app_url(app_server: str) -> str: + return app_server + + +# --------------------------------------------------------------------------- +# DB + admin-HTTP helpers +# --------------------------------------------------------------------------- + + +def _truncate_all() -> None: + """Fresh registry + KB (the E2E isolation pattern): the suites share + one Postgres, and a leftover row would corrupt the 2-doc premise. + ``saved_chats`` is never touched — the unique question texts keep + the row lookups unambiguous, and :func:`_cleanup_saved_rows` owns + this suite's rows (re-runnable on the shared DB).""" + with SessionLocal() as db: + db.execute(text( + "TRUNCATE chunks, documents, query_log, steering_notes, " + "kb_overview, git_sources, sources_meta, folder_summaries" + )) + db.commit() + + +def _health_db(url: str) -> bool: + try: + return httpx.get(f"{url}/api/health", timeout=5).json()["db"] == "up" + except Exception: # noqa: BLE001 — unreachable is the skip case + return False + + +@contextmanager +def _admin_client(app_url: str) -> Iterator[httpx.Client]: + """An httpx client holding a fresh admin session (the form login's + API twin — the house httpx pattern of test_ls_tree_drilldown's + ``synced_kb``), closed on exit.""" + client = httpx.Client(base_url=app_url, timeout=30.0) + try: + r = client.post("/api/login", json={"password": ADMIN_PASSWORD}) + assert r.status_code == 204, r.text + yield client + finally: + client.close() + + +def _wait_sync_done_http(client: httpx.Client, timeout_s: float = 180.0) -> dict[str, Any]: + """Poll the (cookie-authenticated) status endpoint until the run + reaches a terminal state (the test_local_directory_sources pattern).""" + deadline = time.monotonic() + timeout_s + body: dict[str, Any] = {} + while time.monotonic() < deadline: + r = client.get("/api/sync/status") + assert r.status_code == 200, r.text + body = r.json() + if body["state"] in ("success", "failed"): + return body + time.sleep(0.5) + raise AssertionError(f"sync did not reach a terminal state: {body}") + + +def _auto_title(question: str) -> str: + """The phase-50 auto-title convention: the first question, + whitespace-collapsed, capped at 120 chars.""" + return " ".join(question.split())[:120] + + +#: This suite's auto-titles (the shared e2e Postgres keeps +#: ``saved_chats`` across runs — the test_answer_images cleanup +#: concern): a re-run must start clean. +_SUITE_TITLES = tuple( + _auto_title(q) for q in (Q_ORDER, Q_FIGURE, Q_RESTORE, Q_NONADMIN) +) + + +def _cleanup_saved_rows() -> None: + """Delete THIS suite's saved rows by title (best-effort: a failed + delete (DB unreachable — the skip case) never masks the skip).""" + try: + with SessionLocal() as db: + db.execute( + text("DELETE FROM saved_chats WHERE title = ANY(:titles)"), + {"titles": list(_SUITE_TITLES)}, + ) + db.commit() + except Exception: # noqa: BLE001 — DB unreachable: the skip handles it + pass + + +def _wait_saved_row( + client: httpx.Client, title: str, messages: int = 2 +) -> dict[str, Any]: + """Wait for the auto-saved row (phase 55/66: auto-saves are SILENT + — so there is no status line to wait on).""" + deadline = time.monotonic() + 15 + last: dict[str, Any] | None = None + while time.monotonic() < deadline: + r = client.get("/api/chats", timeout=10) + assert r.status_code == 200 + rows = r.json()["chats"] + last = next((c for c in rows if c["title"] == title), None) + if last is not None and last["message_count"] >= messages: + return last + time.sleep(0.2) + raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})") + + +def _create_token(client: httpx.Client, label: str) -> tuple[str, str]: + """Admin-issued token (``POST /api/tokens`` — the house pattern of + test_api_tokens): returns (row id, plaintext).""" + r = client.post("/api/tokens", json={"label": label}, timeout=10) + assert r.status_code == 201, r.text + body = r.json() + assert body["label"] == label + return body["id"], body["token"] + + +def _revoke_token(client: httpx.Client, token_id: str) -> None: + """Revoke the token (a revoked token is dead NOW — A10): the shared + DB keeps no live credential behind the suite.""" + r = client.post(f"/api/tokens/{token_id}/revoke", timeout=10) + assert r.status_code == 204, r.text + + +@pytest.fixture(scope="module") +def seeded(app_url: str, cluster_dir: Path) -> Iterator[dict[str, str]]: + """The story's precondition: the one-source KB (ONE text doc + ONE + image doc) synced under the deterministic mock — registered through + the authenticated API, scanned by the REAL in-process + ``POST /api/sync`` pipeline (walk → chunk → embed → the mock's + vision description for the PNG → overview → version bump). Yields + the seeded image doc's identity (the bytes route + the + description) for the render assertions.""" + if not _health_db(app_url): + pytest.skip("Postgres not reachable — run `podman compose up -d db` first") + _cleanup_saved_rows() # a previous run's rows would be stale (new doc id) + _truncate_all() + with _admin_client(app_url) as client: + r = client.post( + "/api/git-sources", json={"kind": "local", "path": str(cluster_dir)} + ) + assert r.status_code == 201, r.text + r = client.post("/api/sync") + assert r.status_code == 202, r.text + body = _wait_sync_done_http(client) + assert body["state"] == "success", body + detail = body["detail"] + assert detail["added"] == 2, detail # the text doc + the image doc + with SessionLocal() as db: + text_doc = db.scalar( + select(Document).where( + Document.source == SOURCE_NAME, Document.path == TEXT_PATH + ) + ) + img_doc = db.scalar( + select(Document).where( + Document.source == SOURCE_NAME, Document.path == IMG_PATH + ) + ) + assert text_doc is not None, "the text doc must be indexed" + assert img_doc is not None, "the PNG must become a document" + assert img_doc.is_image is True and img_doc.image_path is not None + # content == summary == the mock's description (the ONLY embedded + # text of the image doc — the phase-122 pipeline contract). + assert img_doc.content == IMAGE_DESCRIPTION_ANSWER + assert img_doc.summary == IMAGE_DESCRIPTION_ANSWER + assert text_doc.title == TEXT_TITLE # the first H1 (extract_title) + assert img_doc.title == IMG_TITLE # the file stem (phase 122) + yield { + "image_url": f"/api/documents/{img_doc.id}/image", + "description": IMAGE_DESCRIPTION_ANSWER, + } + _cleanup_saved_rows() + _truncate_all() + + +@pytest.fixture(autouse=True) +def _clean(db_ready: None) -> Iterator[None]: + """Per-test query_log isolation (the KB itself is module-scoped — + the scripted turns never change it, so the registry and the docs + persist across the tests of this module).""" + with SessionLocal() as db: + db.execute(text("TRUNCATE query_log")) + db.commit() + yield + with SessionLocal() as db: + db.execute(text("TRUNCATE query_log")) + db.commit() + + +# --------------------------------------------------------------------------- +# Page helpers (the test_ls_tree_drilldown house pattern) +# --------------------------------------------------------------------------- + +#: Captures the raw SSE ``data:`` payloads of the /api/chat stream +#: (a response clone read in the background) — wire-level assertions +#: for the ``done`` frame's mixed citation, independent of the UI. +SSE_HOOK = """ +() => { + if (window.__sseInstalled) return; + window.__sseInstalled = true; + window.__sseFrames = []; + const origFetch = window.fetch; + window.fetch = async function (...args) { + const res = await origFetch.apply(this, args); + try { + const url = typeof args[0] === 'string' ? args[0] : args[0].url; + if (url.includes('/api/chat')) { + res.clone().text().then((bodyText) => { + for (const block of bodyText.split('\\n\\n')) { + const line = block.trim(); + if (line.startsWith('data: ')) { + window.__sseFrames.push(line.slice(6)); + } + } + }); + } + } catch (e) { /* non-clonable responses: ignored */ } + return res; + }; +} +""" + + +def _install_page_hooks(page: Page) -> None: + page.evaluate(SSE_HOOK) + + +def _frames(page: Page) -> list[dict[str, Any]]: + """The SSE frames captured since the last submit (``_submit`` + clears the buffer), once the hook's background read settles.""" + deadline = time.monotonic() + 30.0 + while True: + raw = page.evaluate("() => window.__sseFrames || []") + parsed = [json.loads(line) for line in raw if line] + if any(f.get("type") == "done" for f in parsed): + return parsed + if time.monotonic() > deadline: + raise AssertionError( + f"SSE hook captured no `done` frame (frames so far: " + f"{len(parsed)}) — hook install failed?" + ) + time.sleep(0.05) + + +def _tool_frames(frames: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [f for f in frames if f.get("type") == "tool"] + + +def _submit(page: Page, question: str) -> None: + page.evaluate("window.__sseFrames = []") + page.fill("#message-input", question) + page.click("#send-btn") + # The user bubble lands synchronously with the submit handler. + expect(page.locator(".msg.user .bubble").last).to_contain_text(question) + + +def _wait_settled(page: Page) -> None: + """The turn is complete: answer text in the bubble, button recovered + (the phase-48 settle wait, the test_agent_document_tools helper).""" + expect( + page.locator(".msg.brain .bubble").last + ).not_to_have_text("", timeout=SETTLE_TIMEOUT_MS) + expect(page.locator("#send-btn")).to_be_enabled(timeout=SETTLE_TIMEOUT_MS) + expect(page.locator("#send-label")).to_have_text("Send", timeout=SETTLE_TIMEOUT_MS) + + +def _wait_answer(page: Page) -> None: + """The scripted read's answer landed (the settle's answer-side half + — used when the turn's wire is not asserted separately).""" + expect( + page.locator(".msg.brain .bubble").last + ).to_contain_text(READ_ANSWER_PREFIX, timeout=SETTLE_TIMEOUT_MS) + _wait_settled(page) + + +def _last_brain(page: Page) -> Locator: + return page.locator(".msg.brain").last + + +def _body_child_classes(page: Page) -> list[str]: + """The LAST brain bubble's ``.msg-body`` direct children's class + names, in DOM order — the cluster-order pin (the phase-130 fix, + asserted exactly: pre-phase markup — buttons inside the chips row, + figures interleaved — fails this sequence).""" + return page.evaluate( + """() => { + const body = [...document.querySelectorAll(".msg.brain")].pop() + .querySelector(".msg-body"); + return [...body.children].map((el) => el.className); + }""" + ) + + +def _assert_cluster_rows( + page: Page, + seeded: dict[str, str], + *, + action_buttons: list[str], +) -> None: + """The phase-130 cluster contract on the LAST brain bubble (the + live and restore paths share it): the chips row holds EXACTLY the + text doc's chip (NO chip for the image doc — the figure IS the + citation, phase 130 task 01), the images row holds the one figure + (the ```` loaded from the bytes route, alt + the visible + caption settling to the document's summary), and the actions row + — the bubble's LAST row — holds exactly the gated buttons + (*action_buttons*; the row-level right-align is + ``_assert_actions_geometry``'s job).""" + bubble = _last_brain(page) + # The three rows exist exactly once each (the bare .msg-meta is the + # chips row — the images/actions/related rows carry their own + # classes). + chips_row = bubble.locator( + ".msg-meta:not(.msg-images):not(.msg-actions):not(.related-docs)" + ) + images_row = bubble.locator(".msg-meta.msg-images") + actions_row = bubble.locator(".msg-meta.msg-actions") + expect(chips_row).to_have_count(1) + expect(images_row).to_have_count(1) + expect(actions_row).to_have_count(1) + # ARIA is kept (the house meta-row convention, unchanged). + expect(chips_row).to_have_attribute("role", "list") + expect(chips_row).to_have_attribute("aria-label", "Sources") + expect(images_row).to_have_attribute("role", "list") + expect(images_row).to_have_attribute("aria-label", "Source images") + expect(actions_row).to_have_attribute("role", "list") + expect(actions_row).to_have_attribute("aria-label", "Answer actions") + + # The chips row: EXACTLY the text doc's chip — and the bubble + # carries NO other chip anywhere (the image row has none — the + # owner's exact ask, TODO L6). + chips = chips_row.locator(".source-chip") + expect(chips).to_have_count(1) + expect(chips.first).to_have_text(TEXT_SP) + expect(chips.first).to_have_attribute("href", TEXT_DOC_URL) + expect(bubble.locator(".source-chip")).to_have_count(1) + + # The images row: the one figure — the citation of the image doc. + # Its href is the documentUrl (the phase-26 escape hatch), the + # loaded from the bytes route, alt + caption settling to the + # document's summary (the fetchContentSummary pattern). + fig = images_row.locator(".source-image") + expect(fig).to_have_count(1) + expect(fig).to_have_attribute("href", IMG_DOC_URL) + img = fig.locator(".source-image-img") + expect(img).to_be_visible(timeout=FIGURE_TIMEOUT_MS) + expect(img).to_have_attribute("src", seeded["image_url"]) + expect(img).to_have_attribute("alt", seeded["description"], timeout=FIGURE_TIMEOUT_MS) + expect(fig.locator(".source-image-caption")).to_have_text( + seeded["description"], timeout=FIGURE_TIMEOUT_MS + ) + + # The actions row: exactly the gated buttons (the cluster — nothing + # else joins it: the notes are absent on a completed turn), and + # NOTHING after it — it is the bubble's LAST row (the "bottom" of + # "bottom right"). + for cls in action_buttons: + expect(actions_row.locator(f".{cls}")).to_have_count(1) + expect(actions_row.locator("button")).to_have_count(len(action_buttons)) + expect(actions_row.locator("~ *")).to_have_count(0) + + +def _assert_actions_geometry(page: Page) -> None: + """The TODO's "bottom right" geometry (bounding boxes, not pixels): + the actions row is BELOW the images row, and the rightmost + button's right edge sits at the row's right edge minus the row's + 0.25rem inline padding (≤ 5px — the per-button auto margins are + gone: the row's ``justify-content: flex-end`` does the + right-align, phase 130 task 03).""" + bubble = _last_brain(page) + a_box = bubble.locator(".msg-meta.msg-actions").bounding_box() + i_box = bubble.locator(".msg-meta.msg-images").bounding_box() + assert a_box and i_box, "the cluster rows must be laid out (visible)" + assert a_box["y"] >= i_box["y"] + i_box["height"] - 1, ( + f"the actions row must be BELOW the images row: actions.y=" + f"{a_box['y']} vs images bottom {i_box['y'] + i_box['height']}" + ) + btn_box = ( + bubble.locator(".msg-meta.msg-actions button").last.bounding_box() + ) + assert btn_box, "the rightmost button must be laid out (visible)" + gap = (a_box["x"] + a_box["width"]) - (btn_box["x"] + btn_box["width"]) + assert 0 <= gap <= 5, ( + f"the rightmost button must sit at the row's right edge (the " + f"only slack is the row's 0.25rem padding): gap={gap}px" + ) + + +# --------------------------------------------------------------------------- +# 1. The live turn: chips row → images row → actions row LAST (the +# owner-visible fix, DOM-asserted), the wire cites both docs +# --------------------------------------------------------------------------- + + +def test_answer_clusters_render_in_order( + page: Page, app_url: str, seeded: dict[str, str], db_ready: None +) -> None: + """The scripted mixed-citation turn (the mock's ``DRILL_TRIGGER`` + scripted read of the text doc; the image doc auto-cited — see the + module docstring): the ``done`` frame cites BOTH docs, and the + last brain bubble's ``.msg-body`` reads as organized clusters top + to bottom — the answer bubble, the chips row (EXACTLY the text + chip — NO chip for the image doc), the images row (the figure, + loaded, caption settled to the canned description), and the + actions row LAST with ALL THREE gated buttons (admin + configured + docs repo) — right-aligned and below the images row (the "bottom + right" geometry).""" + page.set_default_timeout(SETTLE_TIMEOUT_MS) + login(page, app_url, next="/") + expect(page.locator("#kb-banner")).to_be_hidden() + _install_page_hooks(page) + + _submit(page, Q_ORDER) + _wait_settled(page) + + # The WIRE: exactly the scripted read (one tool frame), and the + # done frame carries the mixed citation — the read text doc FIRST + # (the phase-119 read order), then the auto-cited image ref: + # the text ref's ``image_url`` key is ABSENT (the phase-122 + # omission rule), the image ref carries it. No related tier (the + # 2-doc KB), no answer figure (no show_image call). + frames = _frames(page) + assert _tool_frames(frames) == [ + {"type": "tool", "name": "read", "argument": TEXT_SP} + ], _tool_frames(frames) + done = next(f for f in frames if f.get("type") == "done") + assert done["deflected"] is False, done + assert done["sources"] == [ + {"source": SOURCE_NAME, "path": TEXT_PATH, "title": TEXT_TITLE}, + { + "source": SOURCE_NAME, + "path": IMG_PATH, + "title": IMG_TITLE, + "image_url": seeded["image_url"], + }, + ], done["sources"] + assert done["related"] == [] + assert done["suggestions"] == [] + assert "answer_image" not in done + + # The scripted read reached the model and landed in the answer + # (the phase-37 citation shape: the prefix + the document's first + # 80 chars — the H1 renders as a heading, the body quote stays in + # its paragraph). + bubble = _last_brain(page).locator(".bubble") + expect(bubble).to_contain_text(READ_ANSWER_PREFIX) + expect(bubble).to_contain_text(TEXT_TITLE) + expect(bubble).to_contain_text(READ_BODY_QUOTE) + + # The DOM ORDER (the fix): tool record, answer, chips row, images + # row, actions row LAST — asserted exactly (this sequence fails + # against the pre-phase soup div, where the buttons shared the + # chips row and the figure interleaved with it). + assert _body_child_classes(page) == EXPECTED_CHILD_CLASSES, ( + _body_child_classes(page) + ) + + # The clusters + the actions row's contents (all three buttons — + # admin, docs repo configured) + nothing after the actions row. + _assert_cluster_rows( + page, seeded, + action_buttons=["tune-btn", "retry-btn", "save-as-doc-btn"], + ) + _assert_actions_geometry(page) + + +# --------------------------------------------------------------------------- +# 2. No chip for the image doc — the figure is its full citation +# (documentUrl href + the phase-26 same-page modal) +# --------------------------------------------------------------------------- + + +def test_image_without_chip_keeps_its_citation_via_figure( + page: Page, app_url: str, seeded: dict[str, str], db_ready: None +) -> None: + """The owner's exact ask in one bubble: NO ``.source-chip`` whose + label is the image's ``source/path`` (the ONLY chip is the text + doc's), while the image doc is fully cited — the figure's ``href`` + is its ``documentUrl`` and a LEFT-CLICK opens the same-page modal + (the phase-26 affordance, the modal shows the image + the + description). The text chip keeps its own phase-26 affordance + (left-click → the same modal) — the no-behavior-change half.""" + page.set_default_timeout(SETTLE_TIMEOUT_MS) + login(page, app_url, next="/") + expect(page.locator("#kb-banner")).to_be_hidden() + + _submit(page, Q_FIGURE) + _wait_answer(page) + + bubble = _last_brain(page) + # The ONLY chip is the text doc's — there is NO chip for the image + # doc (the figure is the citation — TODO L6). + chips = bubble.locator(".source-chip") + expect(chips).to_have_count(1) + expect(chips.first).to_have_text(TEXT_SP) + + # The figure is the image doc's full citation: the documentUrl + # href, and a left-click opens the same-page modal (phase 26). + fig = bubble.locator(".msg-meta.msg-images .source-image") + expect(fig).to_have_count(1) + expect(fig).to_have_attribute("href", IMG_DOC_URL) + fig.click() + expect(page.locator("#doc-modal")).to_be_visible(timeout=SETTLE_TIMEOUT_MS) + expect(page.locator("#doc-modal-title")).to_have_text(IMG_TITLE) + img = page.locator("#doc-modal .doc-image-img") + expect(img).to_have_count(1) + expect(img).to_have_attribute("src", seeded["image_url"]) + expect(page.locator("#doc-modal pre.doc-raw")).to_have_text( + seeded["description"] + ) + page.keyboard.press("Escape") # the phase-26 close affordance + expect(page.locator("#doc-modal")).to_be_hidden() + + # The text chip keeps its own phase-26 affordance (the no behavior + # change half of the phase): left-click → the same modal, the + # text doc's title + content. + chips.first.click() + expect(page.locator("#doc-modal")).to_be_visible(timeout=SETTLE_TIMEOUT_MS) + expect(page.locator("#doc-modal-title")).to_have_text(TEXT_TITLE) + page.keyboard.press("Escape") + expect(page.locator("#doc-modal")).to_be_hidden() + + +# --------------------------------------------------------------------------- +# 3. Reload: the phase-14 restore path renders the IDENTICAL clusters +# --------------------------------------------------------------------------- + + +def test_restore_renders_the_same_clusters( + page: Page, app_url: str, seeded: dict[str, str], db_ready: None +) -> None: + """The turn auto-saves (phase 66); a page RELOAD restores the + conversation through the phase-14 restore path (the same + appenders — ``renderStoredMessage`` + the after-the-loop + ``markLastRetryable``): the restored last brain bubble carries the + IDENTICAL ``.msg-body`` child sequence (chips row text-only, + images row, ``.msg-actions`` LAST with the three gated buttons) + and the same "bottom right" geometry — the DOM order assertion + pinned for the restored bubble too.""" + page.set_default_timeout(SETTLE_TIMEOUT_MS) + login(page, app_url, next="/") + expect(page.locator("#kb-banner")).to_be_hidden() + + _submit(page, Q_RESTORE) + _wait_answer(page) + _assert_cluster_rows( + page, seeded, + action_buttons=["tune-btn", "retry-btn", "save-as-doc-btn"], + ) + _assert_actions_geometry(page) + live_children = _body_child_classes(page) + + # The auto-save settled (the row exists with both messages) before + # the reload (phase 55/66 — the silent upsert). + with _admin_client(app_url) as client: + _wait_saved_row(client, _auto_title(Q_RESTORE)) + + # RELOAD: the localStorage restore (the done handler's save point + # ran synchronously, so the record is durable before we reload). + page.reload() + expect(page.locator("#kb-banner")).to_be_hidden() + expect(page.locator(".msg.user .bubble")).to_have_count(1) + expect(page.locator(".msg.brain .bubble")).to_have_count(1) + restored = page.locator(".msg.brain .bubble").last + expect(restored).to_contain_text(READ_ANSWER_PREFIX, timeout=SETTLE_TIMEOUT_MS) + + # The IDENTICAL structure (the restore renders through the same + # appenders — the child sequence is the live one, exactly). + assert _body_child_classes(page) == EXPECTED_CHILD_CLASSES == live_children, ( + _body_child_classes(page) + ) + _assert_cluster_rows( + page, seeded, + action_buttons=["tune-btn", "retry-btn", "save-as-doc-btn"], + ) + _assert_actions_geometry(page) + + +# --------------------------------------------------------------------------- +# 4. A non-admin (token user) sees ONLY the Retry in the actions row +# --------------------------------------------------------------------------- + + +def test_non_admin_sees_retry_only_actions( + browser: Browser, + app_url: str, + seeded: dict[str, str], + db_ready: None, +) -> None: + """A FRESH non-admin context (a new browser context — no admin + cookie, no cached token, no localStorage → a FRESH chat) signs in + through the REAL in-app token gate (``login_with_token``, phase + 79) with an admin-issued token, and sends the same scripted turn: + the cluster rows render identically (the render is not + identity-gated — chips + images + the ARIA names), and the + ``.msg-actions`` row holds ONLY the ``.retry-btn`` (Tune admin- + gated phase 16, Save-as-doc admin-gated phase 59 — the gates + unchanged). The token is revoked at the end.""" + with _admin_client(app_url) as client: + token_id, token = _create_token(client, "e2e-clusters-guest") + try: + ctx = browser.new_context(viewport={"width": 1280, "height": 800}) + try: + anon = ctx.new_page() + anon.set_default_timeout(SETTLE_TIMEOUT_MS) + login_with_token(anon, app_url, token, next="/") + expect(anon.locator("#kb-banner")).to_be_hidden() + + _submit(anon, Q_NONADMIN) + _wait_answer(anon) + + bubble = _last_brain(anon) + # The cluster rows are identical for the token user + # (the actions row carries the retry-only cluster). + _assert_cluster_rows(anon, seeded, action_buttons=["retry-btn"]) + # The gates, unchanged: no Tune (phase 16), no + # Save-as-doc (phase 59) — the ONE button in the row + # is the Retry. + actions = bubble.locator(".msg-meta.msg-actions") + expect(actions.locator(".tune-btn")).to_have_count(0) + expect(actions.locator(".save-as-doc-btn")).to_have_count(0) + expect(actions.locator("button")).to_have_count(1) + expect(actions.locator(".retry-btn")).to_have_count(1) + finally: + ctx.close() + finally: + _revoke_token(client, token_id) diff --git a/tests/e2e/test_image_documents.py b/tests/e2e/test_image_documents.py index b42e0ea..a6ad344 100644 --- a/tests/e2e/test_image_documents.py +++ b/tests/e2e/test_image_documents.py @@ -413,12 +413,13 @@ def test_chat_sources_block_shows_the_inline_image( turn chips nothing): the mock's echo carries the agent ``read`` result VERBATIM — including the task-05 ``IMAGE_DOC_MARKER`` line (the model saw the description, not raw bytes) — and the answer's - sources block carries the citation chip (the READ doc clears the - usefulness floor) AND the compact inline figure — the ```` - from the bytes route, the visible caption + ``alt`` settling to - the document's summary (the figure fetches it from the content - endpoint — the frame carries no summary), the "shown in the chat - nicely" contract.""" + sources block carries the citation (the READ doc clears the + usefulness floor) as the compact figure in the images row + (phase 130 task 01: NO chip for an image doc — the figure IS the + citation) — the ```` from the bytes route, the visible + caption + ``alt`` settling to the document's summary (the figure + fetches it from the content endpoint — the frame carries no + summary), the "shown in the chat nicely" contract.""" login(page, app_url, next="/") expect(page.locator("#kb-banner")).to_be_hidden() @@ -433,15 +434,17 @@ def test_chat_sources_block_shows_the_inline_image( expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000) expect(page.locator("#send-label")).to_have_text("Send") - # The citation chip (the text affordance stays — the figure is - # additive, not a replacement). - chip = page.locator(".msg.brain .source-chip") - expect(chip).to_have_count(1) - expect(chip).to_have_text(f"{SOURCE_NAME}/{DOC_PATH}") + # Phase 130 (task 01, TODO L6): NO chip for the image doc — its + # figure is the citation — and the figure sits in its own images + # cluster (.msg-meta.msg-images), not interleaved with a chip. + expect(page.locator(".msg.brain .source-chip")).to_have_count(0) + row = page.locator(".msg.brain .msg-meta.msg-images") + expect(row).to_have_count(1) + expect(row).to_have_attribute("aria-label", "Source images") # The inline figure: image + caption, both settling to the summary # (the content fetch resolves the alt/caption after the title). - fig = page.locator(".msg.brain .source-image") + fig = row.locator(".source-image") expect(fig).to_have_count(1) img = fig.locator(".source-image-img") expect(img).to_be_visible(timeout=15_000) diff --git a/tests/e2e/test_pdf_support.py b/tests/e2e/test_pdf_support.py index 2458adc..40e980a 100644 --- a/tests/e2e/test_pdf_support.py +++ b/tests/e2e/test_pdf_support.py @@ -541,10 +541,13 @@ def test_chat_grounds_on_the_pdf_and_chips_the_embedded_image( PDF (phase 119 A1): the echo carries the read result VERBATIM — the document's FULL extracted text (LOCKED A4 — the page markers and both page texts reach the model and land in the answer), and - the sources block carries the PDF's citation chip (the READ doc) - PLUS the embedded image's AUTO-cited chip (phase 124 — the - suggested-tier image doc) with its compact inline figure (the - bytes route, caption/alt settling to the description).""" + the sources block renders the mixed citation as phase-130 + clusters (task 01): the chips row carries the PDF's citation chip + (the READ doc) and NO chip for the image doc (its figure IS the + citation), while the embedded image's AUTO-cite (phase 124 — the + suggested-tier image doc) sits in the images row as its compact + figure (the bytes route, caption/alt settling to the + description).""" login(page, app_url, next="/") expect(page.locator("#kb-banner")).to_be_hidden() @@ -563,17 +566,21 @@ def test_chat_grounds_on_the_pdf_and_chips_the_embedded_image( expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000) expect(page.locator("#send-label")).to_have_text("Send") - # The citation chips: the READ PDF first (read order), then the - # suggested image doc's auto-cite (phase 124). + # Phase 130 (task 01) — the mixed citation: the chips row carries + # the READ PDF ONLY (no chip for the image doc — the figure IS + # the citation), and the image doc's auto-cite (phase 124) is the + # figure in its own images row. chip = page.locator(".msg.brain .source-chip") - expect(chip).to_have_count(2) + expect(chip).to_have_count(1) expect(chip.nth(0)).to_have_text(f"{SOURCE_NAME}/{DOC_PATH}") - expect(chip.nth(1)).to_have_text(f"{SOURCE_NAME}/{IMAGE_REL}") + row = page.locator(".msg.brain .msg-meta.msg-images") + expect(row).to_have_count(1) + expect(row).to_have_attribute("aria-label", "Source images") - # The inline figure on the image chip: image + caption, both + # The inline figure in the images row: image + caption, both # settling to the description (the content fetch resolves the # summary after the title). - fig = page.locator(".msg.brain .source-image") + fig = row.locator(".source-image") expect(fig).to_have_count(1) img = fig.locator(".source-image-img") expect(img).to_be_visible(timeout=15_000) diff --git a/tests/e2e/test_responsive_polish.py b/tests/e2e/test_responsive_polish.py index 632648c..a9db1ba 100644 --- a/tests/e2e/test_responsive_polish.py +++ b/tests/e2e/test_responsive_polish.py @@ -429,7 +429,10 @@ def test_a11y_landmarks_and_labels( f"skip link must move focus to #main ({path})" ) - # Every button has an accessible name; every img has alt text. + # Every button has an accessible name; every img carries an alt + # ATTRIBUTE (the WCAG contract — an EMPTY alt is the legitimate + # decorative marker, e.g. the phase-123 attach-preview + # thumbnail, pinned in tests/unit/test_chat_image_questions.py). unnamed = page.evaluate( """() => [...document.querySelectorAll("button")] .filter((b) => !(b.getAttribute("aria-label") || b.textContent.trim())) @@ -437,9 +440,10 @@ def test_a11y_landmarks_and_labels( ) assert unnamed == 0, f"{unnamed} button(s) without an accessible name on {path}" bad_imgs = page.evaluate( - "() => [...document.querySelectorAll('img')].filter((i) => !i.alt).length" + """() => [...document.querySelectorAll('img')] + .filter((i) => !i.hasAttribute('alt')).length""" ) - assert bad_imgs == 0, f"{bad_imgs} without alt on {path}" + assert bad_imgs == 0, f"{bad_imgs} without an alt attribute on {path}" # A real keyboard Tab walk: every focused element shows a visible # :focus-visible outline (solid, >= 2px). Start from the top of the diff --git a/tests/unit/test_frontend_actions_cluster.py b/tests/unit/test_frontend_actions_cluster.py new file mode 100644 index 0000000..935fc5d --- /dev/null +++ b/tests/unit/test_frontend_actions_cluster.py @@ -0,0 +1,421 @@ +"""Unit: the phase-130 (task 02, TODO L6) chat actions cluster. + +The three answer actions (Tune / Retry / Save-as-doc) and the +answer-status notes (Stopped / Failed) stop joining whatever the first +``.msg-meta`` row happens to be (usually the source chips row). They +get-or-create a dedicated ``.msg-meta.msg-actions`` row — the bubble's +LAST row, right-aligned by CSS (task 03) — so chips and images can +never push the buttons around. + +Pins (the house source-pin pattern — no browser): + +* ``getActionsRow(wrap)`` — the shared get-or-create helper: scoped to + the ``msg-actions`` class so it NEVER grabs the chips row (or any + other meta row), ``role=list`` + ``aria-label="Answer actions"``, + null without a ``.msg-body``; +* all FIVE appenders/notes route through ``getActionsRow(wrap)`` — + none of them reuses the first ``.msg-meta`` row anymore; +* ``markLastRetryable`` re-appends the Retry button into the actions + row and the phase-59 save-button re-append hack is GONE (the row's + row-level right-align replaces the per-button auto margins); +* the call-order audit (task 02 item 5): at every completed-bubble + render site the order is answer figure → sources → related → the + action appenders, so ``.msg-actions`` is the bubble's LAST row, and + the done branch's insertion order inside the row is Tune → Retry → + Save-as-doc. + +The browser-level assertions (the right-aligned cluster, the row +order in the rendered DOM, the restore parity) are E2E-gated by +tests/e2e/test_chat_answer_clusters.py (task 04). +""" +from __future__ import annotations + +from pathlib import Path + +FRONTEND = Path(__file__).resolve().parents[2] / "frontend" +APP_JS = FRONTEND / "assets" / "app.js" + + +def _js() -> str: + return APP_JS.read_text(encoding="utf-8") + + +def _fn_body(js: str, header: str) -> str: + """The full source of the function whose header is *header* — + brace-matched from the first ``{`` OUTSIDE the parameter list + (paren depth 0, the house convention), so destructured parameters + are skipped (the pinned functions carry no string literal with a + stray brace).""" + start = js.index(header) + i = start + paren = 0 + while i < len(js): + c = js[i] + if c == "(": + paren += 1 + elif c == ")": + paren -= 1 + elif c == "{" and paren == 0: + break + i += 1 + depth = 0 + for j in range(i, len(js)): + c = js[j] + if c == "{": + depth += 1 + elif c == "}": + depth -= 1 + if depth == 0: + return js[start : j + 1] + raise AssertionError(f"unbalanced braces in {header!r}") + + +def _done_branch(js: str) -> str: + """The SSE ``done`` branch — from the branch test to the next + ``else if`` (flat block, the slice is exact).""" + start = js.index('ev.type === "done"') + return js[start : js.index('ev.type === "tool_result"', start)] + + +# --------------------------------------------------------------------------- +# getActionsRow — the shared get-or-create helper +# --------------------------------------------------------------------------- + + +def test_get_actions_row_gets_or_creates_the_dedicated_row() -> None: + """``getActionsRow(wrap)`` find-or-creates the bubble's + ``.msg-meta.msg-actions`` row: the query is scoped to the + ``msg-actions`` class (NEVER the bare first ``.msg-meta`` — that + would grab the chips row), the created row is ``role=list`` + + ``aria-label="Answer actions"`` (the ARIA convention of the other + meta rows), and it lands in ``.msg-body``. A missing ``.msg-body`` + (the defensive pre-phase convention) returns null — the appenders + no-op.""" + js = _js() + body = _fn_body(js, "function getActionsRow(wrap) {") + assert 'body.querySelector(".msg-meta.msg-actions")' in body, ( + "the reuse query is scoped to the msg-actions class — the " + "helper must NEVER grab the chips row (or any other meta row)" + ) + assert "document.querySelector" not in body and "body.querySelector(\".msg-meta\")" not in body + assert 'row.className = "msg-meta msg-actions";' in body + assert 'row.setAttribute("role", "list");' in body, ( + "the row is an accessible list (the house meta-row convention)" + ) + assert 'row.setAttribute("aria-label", "Answer actions");' in body, ( + "the row's accessible name (WCAG — the row is never unnamed)" + ) + assert "body.appendChild(row)" in body, "a new row lands in .msg-body" + assert 'wrap?.querySelector?.(".msg-body")' in body, ( + "defensive .msg-body lookup (the stopped-note convention)" + ) + assert "if (!body) return null" in body, "no .msg-body → null (appenders no-op)" + assert "return row" in body + + +def test_get_actions_row_is_the_shared_helper() -> None: + """EXACTLY ONE getActionsRow (no per-appender copies of the + get-or-create logic), and the created row's class string appears + nowhere else in the file (the appenders only ever LOOK it up — + they never build a second actions row).""" + js = _js() + assert js.count("function getActionsRow(wrap) {") == 1, ( + "exactly ONE getActionsRow — the shared helper" + ) + assert js.count('row.className = "msg-meta msg-actions";') == 1, ( + "the row is created in exactly ONE place — the helper; the " + "appenders never build a row themselves" + ) + assert js.count('body.querySelector(".msg-meta.msg-actions")') == 1, ( + "the reuse query lives in exactly ONE place — the helper" + ) + + +# --------------------------------------------------------------------------- +# the five appenders/notes — all through getActionsRow +# --------------------------------------------------------------------------- + + +#: (function header, the one-per-bubble guard's class, the child class) +APPENDERS = [ + ("function appendTuneButton(wrap) {", ".tune-btn", "tune-btn"), + ("function appendRetryButton(wrap) {", ".retry-btn", "retry-btn"), + ( + "function appendSaveAsDocButton(wrap, markdown) {", + ".save-as-doc-btn", + "save-as-doc-btn", + ), + ("function appendStoppedNote(wrap) {", ".stopped-note", "stopped-note"), + ("function appendFailedNote(wrap, detail) {", ".failed-note", "failed-note"), +] + + +def test_all_action_appenders_join_the_actions_row() -> None: + """All FIVE appenders/notes (Tune, Retry, Save-as-doc, Stopped, + Failed) route through ``getActionsRow(wrap)`` and no-op on its + null (no ``.msg-body``) — none of them reuses the FIRST + ``.msg-meta`` row anymore (the pre-phase-130 soup-div behavior is + gone from every appender).""" + js = _js() + for header, guard, child in APPENDERS: + body = _fn_body(js, header) + assert "getActionsRow(wrap)" in body, f"{header} must use getActionsRow" + assert 'querySelector(".msg-meta")' not in body, ( + f"{header} must not query the bare first .msg-meta row" + ) + assert "if (!meta) return" in body, ( + f"{header} must no-op when getActionsRow returns null" + ) + assert f'querySelector("{guard}")' in body, ( + f"{header} keeps its one-per-bubble guard" + ) + assert f'"{child}"' in body, f"{header} keeps its child class" + + +def test_action_appenders_keep_the_listitem_aria() -> None: + """ARIA is kept (the existing convention): the role=list check + stays on every appender and the child joins the row as a + listitem (the row is ALWAYS role=list — getActionsRow sets it — + so the check always applies).""" + js = _js() + for header, _, _ in APPENDERS: + body = _fn_body(js, header) + assert 'meta.getAttribute("role") === "list"' in body, ( + f"{header} keeps the role=list check" + ) + assert 'setAttribute("role", "listitem")' in body, ( + f"{header} keeps the listitem join" + ) + + +def test_tune_and_save_gates_are_unchanged() -> None: + """The gates are untouched (no behavior change in completed work): + Tune is admin-gated (phase 16) and Save-as-doc is admin-gated AND + docs-repo-gated (phase 59) — both BEFORE any row work. Retry is + NOT admin-gated (owner-locked: every visitor).""" + js = _js() + tune = _fn_body(js, "function appendTuneButton(wrap) {") + assert 'if (!isAdmin) return' in tune + save = _fn_body(js, "function appendSaveAsDocButton(wrap, markdown) {") + assert "if (!isAdmin || !docsRepoConfigured) return" in save + retry = _fn_body(js, "function appendRetryButton(wrap) {") + assert "isAdmin" not in retry, "Retry stays NOT admin-gated" + + +def test_failed_note_detail_is_textcontent_only() -> None: + """The ``failed-detail`` contract survives (task 02 item 3): the + error detail goes through ``textContent`` — no HTML from error + strings, ever.""" + js = _js() + body = _fn_body(js, "function appendFailedNote(wrap, detail) {") + assert 'd.className = "failed-detail"' in body + assert "d.textContent = detail" in body, ( + "the error string is NEVER innerHTML" + ) + # The icon constant is the ONLY innerHTML (the static aria-hidden + # SVG) — the detail path never builds HTML (comments stripped — + # the "NEVER innerHTML" remark is not code). + code = "\n".join( + line for line in body.splitlines() if "//" not in line + ) + assert code.count("innerHTML") == 1, ( + "only the static FAILED_ICON innerHTML remains in the note" + ) + + +# --------------------------------------------------------------------------- +# markLastRetryable — the rework (task 02 item 4) +# --------------------------------------------------------------------------- + + +def test_mark_last_retryable_moves_retry_into_the_actions_row() -> None: + """``markLastRetryable`` keeps the phase-49 remove-then-append + shape (remove every rendered .retry-btn FIRST, then re-append on + the last brain bubble — only when its preceding user record + exists), but the re-append now lands in the bubble's .msg-actions + row (via appendRetryButton → getActionsRow). The phase-59 + save-button re-append hack (the ``saveDocBtn`` block) is DELETED: + the row's row-level right-align (task 03) right-aligns the whole + cluster, and the DOM order inside the row is the insertion order. + The 'auto margins split the free space' rationale is gone with it.""" + js = _js() + body = _fn_body(js, "function markLastRetryable() {") + assert 'querySelectorAll(".retry-btn")' in body + assert ".remove()" in body + assert body.index(".remove()") < body.index("appendRetryButton(lastBrainWrap)"), ( + "the existing buttons are removed before the new one is appended" + ) + assert "appendRetryButton(lastBrainWrap)" in body, ( + "the re-append targets the last brain wrap (via getActionsRow)" + ) + assert 'who !== "user"' in body, "the preceding-user-record guard stays" + assert "saveDocBtn" not in body, ( + "the phase-59 save-button re-append hack is deleted" + ) + assert "parentElement" not in body, ( + "no DOM re-parenting of the save button anymore" + ) + assert "auto margins" not in body, ( + "the 'auto margins split the free space' rationale is gone" + ) + # The hack had no other home in the file. + assert "saveDocBtn" not in js + + +# --------------------------------------------------------------------------- +# the call-order audit (task 02 item 5) — .msg-actions is the LAST row +# --------------------------------------------------------------------------- + + +def test_done_branch_appends_actions_last_in_insertion_order() -> None: + """The live ``done`` branch (a completed brain bubble): the row + order is the DOM order — the answer figure, then the source + clusters (appendSources), then the related row, then the action + appenders — so the .msg-actions row is the bubble's LAST row. + Inside the row the insertion order is Tune → Retry (the + markLastRetryable re-append) → Save-as-doc.""" + js = _js() + branch = _done_branch(js) + for marker in ( + "appendSources(wrap, ev.sources);", + "appendRelated(wrap, ev.related);", + "appendTuneButton(wrap);", + "markLastRetryable();", + 'appendSaveAsDocButton(wrap, finalText || acc || "…");', + ): + assert marker in branch, f"the done branch must keep {marker!r}" + order = [ + branch.index("appendSources(wrap, ev.sources);"), + branch.index("appendRelated(wrap, ev.related);"), + branch.index("appendTuneButton(wrap);"), + branch.index("markLastRetryable();"), + branch.index('appendSaveAsDocButton(wrap, finalText || acc || "…");'), + ] + assert order == sorted(order), ( + "done branch: sources → related → Tune → Retry → Save-as-doc " + "(the .msg-actions row is the bubble's LAST row — phase 130 " + "task 02; the row's inner order is the insertion order)" + ) + # lastBrainWrap is set before the Retry re-append reads it. + assert branch.index("lastBrainWrap = wrap") < branch.index( + "markLastRetryable();" + ) + + +def test_zero_frame_fallback_appends_actions_last() -> None: + """The zero-frame-but-completed fallback bubble (the stream + settled with no events): a failed turn — Tune + the Failed note + + Retry (markLastRetryable) + Save-as-doc, all in the bubble's + .msg-actions row (the only row it has), the note BEFORE the Retry + re-append (the 'note + Retry' reading), the save button LAST in + the row (the phase-59 parity with the done path).""" + js = _js() + zero = js.index("if (!aborted && !wrap) {") + block = js[zero : js.index("} catch (err) {", zero)] + for marker in ( + "appendTuneButton(fwrap);", + "appendFailedNote(fwrap, nothing);", + "markLastRetryable();", + "appendSaveAsDocButton(fwrap, fallback);", + ): + assert marker in block, f"the fallback block must keep {marker!r}" + order = [ + block.index("appendTuneButton(fwrap);"), + block.index("appendFailedNote(fwrap, nothing);"), + block.index("markLastRetryable();"), + block.index("appendSaveAsDocButton(fwrap, fallback);"), + ] + assert order == sorted(order), ( + "fallback: Tune → Failed note → Retry → Save-as-doc — the " + "actions row holds the whole cluster (phase 130 task 02)" + ) + + +def test_stop_finalize_appends_actions_last() -> None: + """The stop finalize (a stopped partial — the prime retry + candidate): Tune + the Stopped note + Retry (markLastRetryable), + all in the actions row — the stop path renders no sources or + related rows, so the actions row IS the bubble's only (and last) + row. The order is Tune → note → Retry (the 'note + Retry' + reading), unchanged from pre-phase (no save button — a stopped + partial is a note, not an answer).""" + js = _js() + catch = js.index("} catch (err) {") + stop = js.index('stoppedByUser || err?.name === "AbortError"', catch) + branch = js[stop : js.index("} else {", stop)] + for marker in ( + "appendTuneButton(wrap);", + "appendStoppedNote(wrap);", + "markLastRetryable();", + ): + assert marker in branch, f"the stop branch must keep {marker!r}" + order = [ + branch.index("appendTuneButton(wrap);"), + branch.index("appendStoppedNote(wrap);"), + branch.index("markLastRetryable();"), + ] + assert order == sorted(order), ( + "stop: Tune → Stopped note → Retry (phase 130 task 02 — the " + "note + Retry in the actions row)" + ) + assert "appendSaveAsDocButton" not in branch, ( + "the stopped partial (note, not answer) still gets no save button" + ) + + +def test_failed_funnel_note_and_retry_join_the_actions_row() -> None: + """The phase-120 failure funnel (finalizeFailedTurn): the Failed + note is appended and the funnel ENDS with markLastRetryable — the + note + Retry in the failed bubble's actions row (the partial + keeps its streamed text; the zero-frame shape gets a plain + FAILED_TURN_TEXT bubble). Both shapes end retryable.""" + js = _js() + header = ( + "function finalizeFailedTurn(detail, " + "{ acc, thinking, tools, wrap, leavePartialIndex }) {" + ) + body = _fn_body(js, header) + assert body.count("appendFailedNote(") == 2, ( + "both funnel shapes add the in-bubble error note" + ) + lines = [line for line in body.splitlines() if line.strip()] + assert lines[-1].strip() == "}" and lines[-2].strip().startswith( + "markLastRetryable();" + ), ( + "the funnel still ends with the markLastRetryable() call — the " + "Retry button lands on the failed bubble's actions row" + ) + + +def test_restore_path_appends_actions_last() -> None: + """The phase-14 restore path (renderStoredMessage): the answer + figure → sources → related → the action appenders (Tune / + Save-as-doc / the Stopped + Failed notes) — so the .msg-actions + row a restored bubble gets is its LAST row too, identical to the + live done path. The restore loop's after-the-loop + markLastRetryable() then lands the Retry button in that same row + (on the LAST restored brain bubble).""" + js = _js() + body = _fn_body(js, "function renderStoredMessage(m) {") + for marker in ( + "appendSources(wrap, m.sources);", + "appendRelated(wrap, m.related);", + "appendTuneButton(wrap)", + "appendSaveAsDocButton(wrap, m.text);", + "appendStoppedNote(wrap);", + "appendFailedNote(wrap, m.error);", + "lastBrainWrap = wrap", + ): + assert marker in body, f"the restore path must keep {marker!r}" + order = [ + body.index("appendSources(wrap, m.sources);"), + body.index("appendRelated(wrap, m.related);"), + body.index("appendTuneButton(wrap)"), + body.index("appendSaveAsDocButton(wrap, m.text);"), + body.index("lastBrainWrap = wrap"), + ] + assert order == sorted(order), ( + "restore: sources → related → the action appenders → " + "lastBrainWrap (the after-the-loop markLastRetryable lands the " + "Retry in the same actions row — phase 130 task 02)" + ) diff --git a/tests/unit/test_frontend_cluster_css.py b/tests/unit/test_frontend_cluster_css.py new file mode 100644 index 0000000..ea9474c --- /dev/null +++ b/tests/unit/test_frontend_cluster_css.py @@ -0,0 +1,245 @@ +"""Unit: the phase-130 (task 03, TODO L6) chat cluster rows — CSS. + +The bubble's metadata reads as organized rows, top to bottom: the +answer text → the answer figure (phase 124, unchanged) → the source +chips row (text refs only) → the source images row (`.msg-images`, +phase 130 task 01) → the "Nearby docs" row (phase 113, unchanged) → +the actions row (`.msg-actions`, phase 130 task 02) — never one soup +div. The DOM is built by app.js (pinned by test_frontend_actions_cluster +and test_source_chip_quality); THIS module pins the row-level layout +that makes the clusters read as clusters: + +* the actions row right-aligns its buttons as a cluster — the "bottom + right" is the row's ``justify-content: flex-end``, and the + per-button auto margins (the phase-49/59 ``.tune-btn`` / + ``.retry-btn`` / ``.save-as-doc-btn`` margin mechanisms) are GONE; +* the images row wraps its figures with the chips row's rhythm (the + base ``.msg-meta`` wrap + 0.35rem gap + center align — pinned + explicitly so the two clusters cannot drift apart); +* the audit (task 03 item 3): the chips row and the related row are + untouched, the ``.source-image`` figure styles are untouched, and + the mobile squeeze pins nothing about the actions row left — the + cluster stays right-aligned at every width. + +House source-pin pattern (no browser); the browser-level bounding-box +assertions are E2E-gated by tests/e2e/test_chat_answer_clusters.py +(task 04). +""" +from __future__ import annotations + +import re +from pathlib import Path + +STYLES_CSS = Path(__file__).resolve().parents[2] / "frontend" / "assets" / "styles.css" + + +def _css() -> str: + return STYLES_CSS.read_text(encoding="utf-8") + + +def _block(css: str, selector: str) -> str: + """The first `` { … }`` rule body — the house pin + pattern (the cluster selectors are unique in the sheet).""" + m = re.search(re.escape(selector) + r" \{([^}]*)\}", css) + assert m, f"styles.css must style {selector}" + return m.group(1) + + +def _media_block(css: str, query: str) -> str: + """The body of the ``@media ()`` block — the house non- + greedy pattern (the sheet indents the block's nested closing + braces, so the FIRST column-0 ``}`` is the block's own end).""" + pattern = "@media \\(" + re.escape(query) + r"\) \{([\s\S]*?)\n\}" + m = re.search(pattern, css) + assert m, f"{query} media query missing" + return m.group(1) + + +# --------------------------------------------------------------------------- +# the actions cluster — right-aligned by the ROW, no per-button margins +# --------------------------------------------------------------------------- + + +def test_actions_row_rule_right_aligns_the_cluster() -> None: + """``.msg-meta.msg-actions`` right-aligns its contents — the TODO's + "bottom right" is the bubble's LAST row (the DOM order, phase 130 + task 02) + this row-level justify. No positioning tricks: the row + keeps the base .msg-meta flex/wrap/rhythm, only the justify moves.""" + css = _css() + assert re.search(r"\.msg-meta\.msg-actions \{", css), ( + "the .msg-meta.msg-actions row rule is missing" + ) + body = _block(css, ".msg-meta.msg-actions") + assert "justify-content: flex-end" in body, ( + "the row's justify does the 'bottom right' (right edge)" + ) + # Row-level only — no absolute/float/transform tricks. + for prop in ("position", "float", "transform", "margin", "padding"): + assert prop not in body, ( + f"the actions row is a plain flex row ({prop} is a " + "positioning trick — the row order is DOM order)" + ) + + +def test_no_per_button_auto_margins_remain() -> None: + """The per-button auto margins (the pre-phase-130 mechanism the + markLastRetryable comment named) are gone from ALL THREE action + buttons — grep-confirmed: the buttons are plain flex items of the + actions row, and the row's justify does the alignment. (Completion + criterion: 'right-aligned by .msg-actions alone'.)""" + css = _css() + for selector in (".tune-btn", ".retry-btn", ".save-as-doc-btn"): + body = _block(css, selector) + assert "margin-left: auto" not in body, ( + f"{selector}: the per-button auto margin must be gone — " + "the .msg-actions row's justify right-aligns the cluster" + ) + assert "margin-inline-start: auto" not in body, ( + f"{selector}: the per-button auto margin must be gone" + ) + assert "margin-right: auto" not in body and "margin-inline-end: auto" not in body + + +def test_button_family_is_untouched() -> None: + """Removing the auto margins is the ONLY change to the button + rules — the phase-49/59 visual family (pill, ≥44px, line border, + ink-soft palette, brand/neutral hovers, 14px glyph) is intact.""" + css = _css() + for selector in (".tune-btn", ".retry-btn", ".save-as-doc-btn"): + body = _block(css, selector) + for prop in ( + "display: inline-flex", + "min-height: 44px", + "padding: 0.35rem 0.8rem", + "border-radius: 999px", + "border: 1px solid var(--line)", + "color: var(--ink-soft)", + "font-size: 0.82rem", + "cursor: pointer", + ): + assert prop in body, f"{selector} must keep the family ({prop})" + assert f"{selector} svg {{ width: 14px; height: 14px" in css + assert f"{selector}:hover" in css + + +# --------------------------------------------------------------------------- +# the images cluster — wraps like the chips row +# --------------------------------------------------------------------------- + + +def test_images_row_rule_wraps_like_the_chips_row() -> None: + """``.msg-meta.msg-images`` pins the chips row's rhythm EXPLICITLY + (wrap + the base .msg-meta 0.35rem gap + center align) — no new + spacing values (task 03: 'match the EXISTING .msg-meta gap/align + values'). A future base-rule change cannot drift the two clusters + apart.""" + css = _css() + assert re.search(r"\.msg-meta\.msg-images \{", css), ( + "the .msg-meta.msg-images row rule is missing" + ) + body = _block(css, ".msg-meta.msg-images") + assert "flex-wrap: wrap" in body, "the figures wrap (many image refs)" + assert "gap: 0.35rem" in body, ( + "the images row's gap MUST equal the chips row's gap " + "(the base .msg-meta 0.35rem — no invented spacing)" + ) + assert "align-items: center" in body, "the row aligns like the chips row" + + +def test_base_msg_meta_rhythm_is_unchanged() -> None: + """The base ``.msg-meta`` rule (the chips row, the related row, and + every meta row's rhythm) is untouched: flex + wrap + 0.35rem gap + + center align + the soft-ink 0.75rem text.""" + css = _css() + body = _block(css, ".msg-meta") + for prop in ( + "font-size: 0.75rem", + "color: var(--ink-soft)", + "display: flex", + "flex-wrap: wrap", + "gap: 0.35rem", + "align-items: center", + ): + assert prop in body, f"the base .msg-meta rhythm must keep {prop}" + + +# --------------------------------------------------------------------------- +# the audit (task 03 item 3) — neighbors untouched +# --------------------------------------------------------------------------- + + +def test_chips_row_is_untouched() -> None: + """The chips row's look is the completed phase-26/113 surface — + mono pill, brand-soft fill, line border, nowrap ellipsis (the + min-width: 0 escape). Phase 130 only removed the buttons from this + row's neighbors, never the chip's styling.""" + css = _css() + body = _block(css, ".source-chip") + for prop in ( + "font-family: var(--mono)", + "background: var(--brand-soft)", + "border: 1px solid var(--line)", + "border-radius: 999px", + "white-space: nowrap", + "min-width: 0", + "text-overflow: ellipsis", + ): + assert prop in body, f".source-chip must keep {prop}" + assert ".source-chip:hover" in css + + +def test_related_row_is_untouched() -> None: + """The phase-113 "Nearby docs" row keeps its de-emphasized look + (dashed border, transparent fill, muted mono links — never a + citation) and still joins the .msg-meta family.""" + css = _css() + body = _block(css, ".related-doc") + for prop in ( + "border: 1px dashed var(--line)", + "background: transparent", + "font-family: var(--mono)", + "white-space: nowrap", + ): + assert prop in body, f".related-doc must keep {prop}" + assert ".related-docs-label" in css + assert "Nearby docs" not in css # the label text lives in app.js, not CSS + + +def test_source_image_figure_is_untouched() -> None: + """The ``.source-image`` figure (phase 122) was already sized for a + row context — the 96px contain img on the theme's surface, the + clamped muted caption. Phase 130 moves it to its OWN row, not to a + new scale.""" + css = _css() + assert "max-height: 96px" in _block(css, ".source-image-img"), ( + "the compact source figure keeps its 96px row-scale cap" + ) + assert "object-fit: contain" in _block(css, ".source-image-img") + assert "-webkit-line-clamp: 2" in _block(css, ".source-image-caption") + # The answer figure (phase 124) is a DIFFERENT scale — untouched. + assert "max-height: 320px" in _block(css, ".answer-image-img") + + +def test_mobile_pins_the_actions_row_left_nowhere() -> None: + """The mobile squeeze (≤640px) may keep the ≥44px touch floor but + must NOT pin the actions row left or restyle it: the cluster stays + right-aligned at every width (the row's justify is width-agnostic, + the .msg-body flex gap stacks the rows).""" + css = _css() + mobile = _media_block(css, "max-width: 640px") + for selector in (".tune-btn", ".retry-btn", ".msg-actions", ".msg-images"): + for m in re.finditer(re.escape(selector) + r" \{([^}]*)\}", mobile): + body = m.group(1) + assert "justify-content" not in body, ( + f"{selector}: the mobile block must not re-justify the " + "cluster (the actions row stays right-aligned)" + ) + assert "margin-left" not in body and "margin-inline" not in body, ( + f"{selector}: the mobile block must not re-margin the " + "cluster (no left pin, no margin reset)" + ) + assert "position" not in body + # The mobile squeeze's only tune/retry rules are the 44px floors + # (pre-existing, untouched by this phase). + assert ".tune-btn { min-height: 44px; }" in mobile + assert ".retry-btn { min-height: 44px; }" in mobile diff --git a/tests/unit/test_frontend_failed_turn.py b/tests/unit/test_frontend_failed_turn.py index 5466649..8dbc98d 100644 --- a/tests/unit/test_frontend_failed_turn.py +++ b/tests/unit/test_frontend_failed_turn.py @@ -84,12 +84,16 @@ def _fn_body(js: str, name: str) -> str: def test_append_failed_note_exists_and_guards_dedup() -> None: """``appendFailedNote(wrap, detail)`` exists and mirrors - ``appendStoppedNote``: it reuses the ``.msg-meta`` row and adds at + ``appendStoppedNote``: it joins the bubble's ``.msg-actions`` row + (getActionsRow — phase 130 task 02, ASSUMPTION A7: the notes are + answer-status markers, the actions row is their home) and adds at most ONE ``.failed-note`` per bubble (the duplicate guard).""" js = _js() body = _fn_body(js, "appendFailedNote") - assert '.msg-body' in body, "the note must land in the bubble's .msg-body" - assert ".msg-meta" in body, "the note rides the existing .msg-meta row" + assert "getActionsRow(wrap)" in body, ( + "the note joins the dedicated .msg-actions row (getActionsRow — " + "phase 130 task 02), never the first .msg-meta row" + ) assert '.failed-note' in body assert ( 'if (meta.querySelector(".failed-note")) return;' in body diff --git a/tests/unit/test_frontend_feedback.py b/tests/unit/test_frontend_feedback.py index c483c46..4d5420d 100644 --- a/tests/unit/test_frontend_feedback.py +++ b/tests/unit/test_frontend_feedback.py @@ -313,18 +313,22 @@ def test_stop_branch_keeps_partial_and_persists_stopped() -> None: def test_stopped_note_helper_and_restore_path() -> None: - """appendStoppedNote: reuses/creates the .msg-meta row exactly like - appendTuneButton (role=list → the span joins as a listitem), one - .stopped-note per bubble — the aria-hidden stop-glyph SVG + the - "Stopped" text (the accessible meaning). The restore path renders it - for records with `m.stopped` (phase-14 optional-field convention — - no version bump).""" + """appendStoppedNote: joins the bubble's ACTIONS row (getActionsRow + — phase 130 task 02, ASSUMPTION A7: the notes are answer-status + markers, the actions row is their home; the role=list row joins the + span as a listitem so ARIA stays valid), one .stopped-note per + bubble — the aria-hidden stop-glyph SVG + the "Stopped" text (the + accessible meaning). The restore path renders it for records with + `m.stopped` (phase-14 optional-field convention — no version + bump).""" js = _js() fn = js.find("function appendStoppedNote") assert fn != -1, "appendStoppedNote must exist" body = js[fn : js.find("\n}\n", fn)] - assert 'querySelector(".msg-meta")' in body, "reuses the meta row when it exists" - assert 'className = "msg-meta"' in body, "creates it otherwise" + assert "getActionsRow(wrap)" in body, ( + "the note joins the dedicated .msg-actions row (getActionsRow — " + "phase 130 task 02), never the first .msg-meta row" + ) assert 'className = "stopped-note"' in body assert 'querySelector(".stopped-note")' in body, "one note per bubble" assert 'note.setAttribute("role", "listitem")' in body @@ -443,19 +447,22 @@ def test_run_turn_is_the_extracted_turn_handler() -> None: def test_retry_button_is_not_admin_gated_and_one_per_bubble() -> None: - """appendRetryButton: the house appendTuneButton pattern — reuses the - .msg-meta row when it exists (role=list → the button joins as a - listitem so ARIA stays valid), creates it otherwise, one .retry-btn - per bubble, the aria-hidden redo glyph + the "Retry" text (the - accessible name). NOT admin-gated — unlike appendTuneButton, chat is - public and every visitor gets the redo (owner-locked).""" + """appendRetryButton: the house pattern through getActionsRow + (phase 130 task 02 — the button joins the bubble's dedicated + .msg-actions row; the role=list row joins it as a listitem so ARIA + stays valid), one .retry-btn per bubble, the aria-hidden redo glyph + + the "Retry" text (the accessible name). NOT admin-gated — unlike + appendTuneButton, chat is public and every visitor gets the redo + (owner-locked).""" js = _js() fn = js.find("function appendRetryButton") assert fn != -1, "appendRetryButton must exist" body = js[fn: js.find("\n}\n", fn)] assert "isAdmin" not in body, "Retry is NOT admin-gated (owner-locked: all visitors)" - assert 'querySelector(".msg-meta")' in body, "reuses the meta row when it exists" - assert 'className = "msg-meta"' in body, "creates it otherwise" + assert "getActionsRow(wrap)" in body, ( + "the button joins the dedicated .msg-actions row (getActionsRow — " + "phase 130 task 02), never the first .msg-meta row" + ) assert 'className = "retry-btn"' in body assert 'querySelector(".retry-btn")' in body, "one Retry button per bubble" assert 'btn.setAttribute("role", "listitem")' in body, ("role=list → listitem") @@ -584,12 +591,14 @@ def test_last_brain_wrap_tracking_and_restore() -> None: def test_retry_btn_css_is_the_tune_family() -> None: """Phase 49 styling: the .retry-btn pill is the exact .tune-btn - visual family (same size/spacing/min-height, right-aligned after - the source chips, 14px glyph) so the two meta actions read as a - pair — with the neutral ink-soft → ink hover (Tune keeps the brand - pair). Contrast: ink-soft on --bg ~8.6:1, ink ~15:1, hover ink on - --brand-soft ~15.7:1 — all AA. The mobile squeeze keeps the >=44px - floor.""" + visual family (same size/spacing/min-height, 14px glyph) so the + meta actions read as a pair — with the neutral ink-soft → ink + hover (Tune keeps the brand pair). The right-align moved to the + ROW in phase 130 (the .msg-meta.msg-actions justify — the button + is a plain flex item with NO per-button auto margin; the + cluster-level pin lives in test_frontend_cluster_css). Contrast: + ink-soft on --bg ~8.6:1, ink ~15:1, hover ink on --brand-soft + ~15.7:1 — all AA. The mobile squeeze keeps the >=44px floor.""" css = _css() block = re.search(r"\.retry-btn \{([\s\S]*?)\n\}", css) assert block, "styles.css must style .retry-btn" @@ -597,7 +606,6 @@ def test_retry_btn_css_is_the_tune_family() -> None: for prop in ( "display: inline-flex", "min-height: 44px", - "margin-left: auto", "padding: 0.35rem 0.8rem", "border-radius: 999px", "border: 1px solid var(--line)", @@ -606,6 +614,10 @@ def test_retry_btn_css_is_the_tune_family() -> None: "cursor: pointer", ): assert prop in body, f".retry-btn must keep the .tune-btn family ({prop})" + assert "margin-left: auto" not in body and "margin-inline-start: auto" not in body, ( + "phase 130: the per-button auto margin is gone — the row's " + ".msg-actions justify right-aligns the cluster" + ) assert re.search(r"\.retry-btn svg \{ width: 14px; height: 14px", css), ( "the redo glyph rides the 14px meta-row size" ) diff --git a/tests/unit/test_image_documents.py b/tests/unit/test_image_documents.py index e678f2a..7f0e42a 100644 --- a/tests/unit/test_image_documents.py +++ b/tests/unit/test_image_documents.py @@ -51,12 +51,19 @@ image doc prefacing the description with the pinned ``IMAGE_DOC_MARKER`` line (header + date line byte-identical, the cap-truncation case marker-before-cut, a text doc's result byte-identical); and the house-style frontend source contracts — the -chat sources block's inline image figure (both the citation chips and -the related row, gated on ``s.image_url``) reads ``image_url``, sets +chat sources block's inline image figure reads ``image_url``, sets ``alt`` + the visible caption to the document's summary (fetched from the content endpoint the chip's modal already uses — the frame -carries no summary), and collapses to the plain chip on an image -load error (never a broken-image icon). The SSE-level byte pin sits in +carries no summary), and becomes the IN-PLACE CHIP FALLBACK on an +image load error (phase 130 task 01: the figure is replaced, in +place, with a plain chip for the same document — never a broken-image +icon; the pre-phase-130 rule collapsed by removing the figure, the +chip staying). Phase 130 (task 01) also re-partitions the citation +block: the chips row holds the NON-image refs only (no chip for an +image doc — its figure IS the citation) and a second +``.msg-meta.msg-images`` row holds the figures in ref order (the +related row keeps its own interleave, unchanged — ASSUMPTION A4). +The SSE-level byte pin sits in ``tests/integration/test_chat_api.py``; the E2E rendering scenario is task 06's isolated story suite. """ @@ -1342,16 +1349,23 @@ def test_read_of_image_doc_over_the_cap_truncates_after_the_marker(db) -> None: # --- the chat sources block: the inline image (house-style frontend) -def test_chat_sources_figure_reads_image_url_sets_alt_and_collapses_on_error() -> None: +def test_chat_sources_figure_reads_image_url_sets_alt_and_falls_back_in_place() -> None: """House-style source contract (app.js appendSourceImageFigure — task 05's "shown in the chat nicely" renderer): the figure's ```` comes from the ref's ``image_url``; ``alt`` is set (title first, then the SUMMARY once the content fetch settles — the frame carries no summary, image_url is its only new field) - and so is the VISIBLE caption; a FAILED image load removes the - whole figure — it collapses to the plain chip, never a - broken-image icon. Properties only (src/alt/textContent) — the - figure builds no innerHTML from document-derived data.""" + and so is the VISIBLE caption. Phase 130 (task 01, ASSUMPTION A3) + REPLACES the old collapse-to-chip rule (``fig.remove()`` — the + chip stayed) with the IN-PLACE CHIP FALLBACK: a FAILED image load + replaces the figure, in place (``fig.replaceWith(chip)``), with a + plain ``.source-chip`` anchor for the same document (the same + ``documentUrl`` href, the same left-click → modal, the label as + text) — there is no chip for image docs anymore, so the figure + itself becomes the citation; never a broken-image icon. The fetch + summary swap is untouched (its ``!fig.isConnected`` guard makes it + a no-op after the swap). Properties only (src/alt/textContent) — + the figure builds no innerHTML from document-derived data.""" js = APP_JS.read_text(encoding="utf-8") body = _function_body(js, "function appendSourceImageFigure(meta, s) {") assert "img.src = s.image_url" in body, "the must come from s.image_url" @@ -1360,10 +1374,34 @@ def test_chat_sources_figure_reads_image_url_sets_alt_and_collapses_on_error() - assert "caption.textContent = s.title || label" in body # visible caption: title assert "caption.textContent = summary" in body # …then the summary assert 'img.addEventListener("error"' in body, "the error handler must exist" - assert "fig.remove();" in body, "a failed load collapses to the plain chip" + assert "fig.remove()" not in body, ( + "the phase-122 collapse rule is RETIRED — the figure is never " + "simply removed (there is no chip left behind to stay)" + ) + # Phase 130 (task 01): the in-place chip fallback — the figure is + # replaced with a plain .source-chip anchor for the same document. + assert "fig.replaceWith(chip);" in body, ( + "a failed load replaces the figure IN PLACE with its chip" + ) + assert 'chip.className = "source-chip";' in body, ( + "the fallback is a PLAIN citation chip" + ) + assert 'chip.setAttribute("role", "listitem");' in body # ARIA stays valid + 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, ( + "the fallback chip keeps the left-click → same-page modal" + ) + assert "e.preventDefault();" in body # no new tab (phase 26) + assert "chip.textContent = label;" in body, ("the label is the chip's text") + assert "chip.title = label;" in body # full path as the native tooltip assert "fetchContentSummary(s.source, s.path)" in body, ( "the frame carries no summary — the figure fetches it" ) + assert "if (!fig.isConnected) return;" in body, ( + "the summary swap is a no-op once the figure was replaced" + ) code = re.sub(r"//.*", "", re.sub(r"/\*.*?\*/", "", body, flags=re.S)) assert "innerHTML" not in code, "no innerHTML from document-derived data" # The figure reuses the chip's navigation (phase 26 contract): @@ -1371,20 +1409,55 @@ def test_chat_sources_figure_reads_image_url_sets_alt_and_collapses_on_error() - assert "openDocumentModal(s.source, s.path, fig);" in body -def test_chat_sources_and_related_rows_gate_the_figure_on_image_url() -> None: - """Both chat source surfaces (the citation chips AND the related - row — the frame's two ref tiers, built by the same server helper) - render the figure ONLY when the ref carries ``image_url``: text - refs (and every pre-phase saved ref) add zero new DOM — the - renderer stays byte-identical for them. The chip's text + - affordance stay (the figure is additive, not a replacement), and - the summary fetch targets the SAME content endpoint the chip's - modal already uses (failing soft to null — the title stands).""" +def test_chat_sources_splits_the_chips_row_from_the_images_row() -> None: + """Phase 130 (task 01, TODO L6): ``appendSources`` stops + interleaving figures after chips — it PARTITIONS the refs on the + existing image-doc signal (the ``image_url`` presence, phase 122) + and builds two clusters: the chips row from the NON-image refs + ONLY (no chip for an image doc — the figure IS the citation) and + a second ``.msg-meta.msg-images`` row (``role=list`` + + ``aria-label="Source images"``) holding one figure per image ref + in ref order. Each row is built only when its refs are non-empty + (image-only citations get no chips row; text-only turns render + the pre-phase-130 single row). The clipped-chip ``aria-label`` + pass stays, over the chips row only. The related row is a + separate tier and is NOT reorganized (ASSUMPTION A4 — its own + chip/figure interleave is untouched), and the summary fetch + targets the SAME content endpoint the chip's modal already uses + (failing soft to null — the title stands).""" js = APP_JS.read_text(encoding="utf-8") src = _function_body(js, "function appendSources(wrap, sources) {") - assert 'if (s.image_url) appendSourceImageFigure(meta, s);' in src - assert 'chip.className = "source-chip";' in src, "the chip stays (additive)" + # The partition (the `image_url` presence is the image-doc signal): + assert "const imageRefs = sources.filter((s) => s.image_url);" in src + assert "const textRefs = sources.filter((s) => !s.image_url);" in src + # Cluster 1 — the chips row, text refs only, skipped when empty: + assert "if (textRefs.length) {" in src, ( + "the chips row is skipped when there are no text refs " + "(an image-only citation still gets its images row)" + ) + assert "for (const s of textRefs) {" in src, ( + "one chip per NON-image ref — an image ref contributes NO chip" + ) + assert 'meta.className = "msg-meta";' in src + assert 'meta.setAttribute("aria-label", "Sources");' in src # ARIA name kept + assert 'chip.className = "source-chip";' in src assert "chip.textContent = label" in src + # The clipped-chip aria-label pass stays, over the chips row only: + assert "chip.scrollWidth > chip.clientWidth" in src + # Cluster 2 — the images row, image refs only, absent when empty: + assert "if (imageRefs.length) {" in src, ( + "the images row is built only when image refs exist" + ) + assert 'images.className = "msg-meta msg-images";' in src + assert 'images.setAttribute("role", "list");' in src + assert 'images.setAttribute("aria-label", "Source images");' in src + assert "for (const s of imageRefs) appendSourceImageFigure(images, s);" in src, ( + "one figure per image ref, in ref order, no chips in the row" + ) + # The phase-122 interleave is gone from appendSources (the figure + # no longer rides right after a chip in the chips row): + assert "if (s.image_url) appendSourceImageFigure(meta, s);" not in src + # The related tier is NOT reorganized (ASSUMPTION A4): rel = _function_body(js, "function appendRelated(wrap, related) {") assert 'if (s.image_url) appendSourceImageFigure(row, s);' in rel fetch = _function_body(js, "function fetchContentSummary(source, path) {") diff --git a/tests/unit/test_save_as_doc_button.py b/tests/unit/test_save_as_doc_button.py index f2d87be..24d3152 100644 --- a/tests/unit/test_save_as_doc_button.py +++ b/tests/unit/test_save_as_doc_button.py @@ -139,8 +139,9 @@ def test_app_js_button_gates_on_admin_and_configured() -> None: """The single guard: admin (the whoami gate Tune uses) AND docs_repo_configured — otherwise the function injects NOTHING (anonymous, or unconfigured admin, or deflected scope — same as - Tune). One button per bubble; the .msg-meta row is reused (or - created plain) and a role=list row gets a listitem button (ARIA).""" + Tune). One button per bubble; the button joins the bubble's + .msg-actions row (getActionsRow — phase 130 task 02; the role=list + row gets a listitem button, ARIA).""" js = _text(APP_JS) fn_idx = js.find("function appendSaveAsDocButton(wrap, markdown) {") assert fn_idx != -1, "appendSaveAsDocButton missing" @@ -354,23 +355,36 @@ def test_app_js_post_payload_and_navigation() -> None: assert "Couldn't save the answer as a doc" in fn_body -def test_app_js_retry_landing_keeps_save_rightmost() -> None: - """markLastRetryable re-appends the save button AFTER the Retry - button lands on the same (last) bubble — the auto-margined buttons - split the row's free space between them, so DOM order decides the - right edge: "Save as doc" stays the bottom-right action even on the - last bubble (which also carries Retry).""" +def test_app_js_retry_landing_has_no_save_reappend_hack() -> None: + """Phase 130 (task 02): markLastRetryable re-appends the Retry + button into the last bubble's .msg-actions row (appendRetryButton → + getActionsRow) and NO LONGER re-appends the save button after it — + the row's row-level right-align (phase 130 task 03) right-aligns + the WHOLE cluster, and the DOM order inside the row is the + insertion order (the live call sites append Tune → Retry → + Save-as-doc per their gates). The phase-59 "auto margins split the + free space" hack is gone with the per-button auto margins it worked + around.""" js = _text(APP_JS) fn_idx = js.find("function markLastRetryable() {") assert fn_idx != -1 fn_end = js.find("/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the bottom-right", fn_idx) fn_body = js[fn_idx:fn_end] - assert "appendRetryButton(lastBrainWrap);" in fn_body - assert 'lastBrainWrap.querySelector(".save-as-doc-btn")' in fn_body - # "saveDocBtn" — NOT "saveBtn": phase 55 pins the Save pill's - # identifier gone from app.js (substring), so the local stays distinct. - assert "saveDocBtn.parentElement.appendChild(saveDocBtn)" in fn_body - assert "saveBtn" not in _text(APP_JS), ( + assert "appendRetryButton(lastBrainWrap);" in fn_body, ( + "the Retry button is re-appended to the last brain bubble" + ) + assert "saveDocBtn" not in fn_body, ( + "the phase-59 save-button re-append hack is gone — the actions " + "row's right-align (phase 130 task 03) replaces it" + ) + assert "parentElement" not in fn_body, ( + "no DOM re-parenting of the save button anymore" + ) + # "saveDocBtn" is gone from the WHOLE file (the hack had no other + # home); "saveBtn" (the phase-55 retired Save pill's identifier) + # stays absent too. + assert "saveDocBtn" not in js + assert "saveBtn" not in js, ( "the phase-55 pin: no saveBtn identifier in app.js" ) @@ -381,16 +395,24 @@ def test_app_js_retry_landing_keeps_save_rightmost() -> None: def test_styles_css_save_as_doc_btn_is_right_aligned() -> None: - """.save-as-doc-btn exists, carries the bottom-right declaration - (margin-inline-start: auto) and the .tune-btn visual family (pill, - >=44px target, line border, ink-soft palette); :focus-visible is - the global rule, the hover rule is per-class.""" + """.save-as-doc-btn exists in the .tune-btn visual family (pill, + >=44px target, line border, ink-soft palette) with NO per-button + auto margin — phase 130 moved the bottom-right requirement to the + ROW (the .msg-meta.msg-actions justify right-aligns the whole + actions cluster; the cluster-level pin lives in + test_frontend_cluster_css). :focus-visible is the global rule, the + hover rule is per-class.""" css = _text(STYLES_CSS) m = re.search(r"\.save-as-doc-btn \{[^}]*\}", css) assert m, "the .save-as-doc-btn rule is missing" block = m.group(0) - assert "margin-inline-start: auto;" in block, ( - "the bottom-right requirement lives on the button's class" + assert "margin-inline-start: auto" not in block and "margin-left: auto" not in block, ( + "phase 130: the per-button auto margin is gone — the row's " + ".msg-actions justify does the bottom right" + ) + row = re.search(r"\.msg-meta\.msg-actions \{[^}]*\}", css) + assert row and "justify-content: flex-end" in row.group(0), ( + "the bottom-right requirement now lives on the .msg-actions row" ) assert "min-height: 44px;" in block # WCAG touch target (the family) assert "border-radius: 999px;" in block diff --git a/tests/unit/test_source_chip_quality.py b/tests/unit/test_source_chip_quality.py index 72a12ef..d092f2e 100644 --- a/tests/unit/test_source_chip_quality.py +++ b/tests/unit/test_source_chip_quality.py @@ -223,26 +223,35 @@ def test_append_related_row_contract() -> None: # ---------- task 02: wiring — the done frame and the restore path ---------- -def test_done_handler_calls_append_related_last() -> None: +def test_done_handler_appends_related_above_the_actions_row() -> None: """The done-frame handler calls ``appendRelated(wrap, ev.related)`` - — and appends the row LAST among the bubble's meta rows: AFTER - ``markLastRetryable()`` (the appendTuneButton / appendSaveAsDoc / - appendRetryButton claimers all take the FIRST ``.msg-meta`` row, - so a deflected turn with related docs — no citation row — gets its - OWN meta row for the buttons instead of actions joining the - related row). The call sits after the Retry claim in the branch.""" + — and appends the row BELOW the citation clusters and ABOVE the + actions row (phase 130 task 02): the action appenders (appendTune + Button → markLastRetryable's Retry → appendSaveAsDocButton) FOLLOW + it in the branch, so the get-or-created ``.msg-actions`` row is the + bubble's LAST row (the bottom-right cluster — DOM order is visual + order). The getActionsRow scoping keeps the buttons out of the + related row either way: a deflected turn with related docs gets its + OWN actions row, never a button in the related row.""" branch = _done_branch(_app_js()) assert "appendRelated(wrap, ev.related);" in branch, ( "the done handler must render the related tier (it arrives in " "ev.related — on a deflected turn ev.sources is empty and the " "weak hits live here, row only, zero chips)" ) - assert branch.index("appendRelated(wrap, ev.related);") > branch.index( - "markLastRetryable();" + assert branch.index("appendRelated(wrap, ev.related);") < branch.index( + "appendTuneButton(wrap);" ), ( - "appendRelated must run AFTER the meta-action claimers (last " - "meta row) — a deflected turn's tune/retry buttons must land in " - "their own row, never in the related row" + "appendRelated must run BEFORE the action appenders — the " + ".msg-actions row is the bubble's LAST row (phase 130 task 02)" + ) + # The insertion order inside the row: Tune → Retry → Save-as-doc + # (markLastRetryable re-appends the Retry button between them). + assert branch.index("appendTuneButton(wrap);") < branch.index( + "markLastRetryable();" + ) < branch.index('appendSaveAsDocButton(wrap, finalText || acc || "…");'), ( + "the actions row's DOM order is the insertion order: Tune, " + "Retry, Save-as-doc (phase 130 task 02)" ) @@ -266,25 +275,30 @@ def test_done_handler_persists_related_with_the_turn() -> None: ) -def test_restore_path_calls_append_related() -> None: +def test_restore_path_appends_related_above_the_actions_row() -> None: """The phase-14 restore path (``renderStoredMessage`` — used by BOTH the localStorage restore and the /?chat= saved-chat boot load) re-renders the related row from the stored payload when it carries ``related``; a pre-phase record without the field restores exactly as today (appendRelated no-ops — no row). The - call sits after the meta-row claimers, exactly like the live - done path, so restored buttons never join the related row.""" + call sits BEFORE the action appenders (Tune / Save-as-doc / notes — + plus the restore loop's after-the-loop markLastRetryable Retry), + so the .msg-actions row is the restored bubble's LAST row too — + identical order to the live done path (phase 130 task 02); the + getActionsRow scoping keeps restored buttons out of the related + row.""" body = _function_body(_app_js(), "function renderStoredMessage(m) {") assert "appendRelated(wrap, m.related);" in body, ( "the restore path must render the related tier from the stored " "payload (pre-phase records carry no `related` → no row, " "graceful)" ) - assert body.index("appendRelated(wrap, m.related);") > body.index( - "appendStoppedNote(wrap);" + assert body.index("appendRelated(wrap, m.related);") < body.index( + "appendTuneButton(wrap)" ), ( - "the related row appends LAST (after the claimers) on the " - "restore path too — identical order to the live done path" + "the related row appends BEFORE the action appenders on the " + "restore path too (the actions row is the bubble's LAST row) — " + "identical order to the live done path (phase 130 task 02)" )