phase: 130_chat_answer_clusters
Build and Push Containers / build-and-push-app (push) Successful in 2m27s
Build and Push Containers / build-and-push-db (push) Successful in 12s

**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/).
This commit is contained in:
2026-09-28 07:49:37 -04:00
parent 2d0f608246
commit c88c65641a
46 changed files with 3266 additions and 351 deletions
@@ -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/).
@@ -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
@@ -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`.
@@ -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
@@ -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`.
@@ -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
@@ -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).
@@ -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
@@ -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).
@@ -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
@@ -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).
@@ -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
@@ -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`.
@@ -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
@@ -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`.
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

+4 -2
View File
@@ -13,8 +13,10 @@
Playwright E2E test suite (one test file per story, run in isolation). Playwright E2E test suite (one test file per story, run in isolation).
5. **"UI Structure Check":** before finalizing any UI component, verify that 5. **"UI Structure Check":** before finalizing any UI component, verify that
it follows the layout principles in `.agents/PLAN.md` §7 (proper container it follows the layout principles in `.agents/PLAN.md` §7 (proper container
usage, centered 46rem chat column, full-width tables on Sources — no usage, the 72rem centered reading column — every page rides the 72rem
skinny wasted-space lists) and meets WCAG 2.1 AA basics (semantic 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 landmarks, labels, contrast ≥4.5:1, focus-visible, aria-live for the
stream). stream).
6. **"No CDN Rule":** all CSS, JS, fonts, and images must be served statically 6. **"No CDN Rule":** all CSS, JS, fonts, and images must be served statically
+20 -13
View File
@@ -74,8 +74,9 @@ client-side router. Direct URLs deep-link to the matching view.
### Chat (`/`) ### Chat (`/`)
Ask questions; answers stream in with **source chips** that cite the exact Ask questions; answers stream in with **source chips** that cite the exact
documents used. Clicking a chip opens the document in an **almost-fullscreen documents used (a cited image document gets no chip — its compact
modal** on the same page (no new tab). The onboarding suggestion chips 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 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 chat's first question — a follow-up never chips, phase 103) — on a
fresh deployment they seed from `BOR_SUGGESTIONS`. fresh deployment they seed from `BOR_SUGGESTIONS`.
@@ -451,9 +452,13 @@ first-class document:
with a small lazy-loaded thumbnail (progressive enhancement — a fetch with a small lazy-loaded thumbnail (progressive enhancement — a fetch
failure falls back to the doc icon), the document viewer renders the failure falls back to the doc icon), the document viewer renders the
image with the description below it, and a cited image doc renders image with the description below it, and a cited image doc renders
**inline in the chat's sources block** (alt text = the description). **in the chat's sources images row** (phase 130: its own
The agent's `read` tool returns the description with a marker telling `.msg-meta.msg-images` cluster below the chips row — no chip for an
the model it is reading an image document. 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 - **Prune guard.** While `BOR_IMAGES` is **off**, syncs never prune
existing image documents — an image doc is invisible to the walk, not 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 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 (enforced server-side); the text answer is always given in full — the
image supplements, never replaces. image supplements, never replaces.
- **Rendered as an answer figure.** A shown image appears as a - **Rendered as an answer figure.** A shown image appears as a
first-class figure below the answer text and above the source chips — first-class figure below the answer text and above the source
on the live turn, after a page reload, and on the shared chat page; a clusters (the chips row, then the images row — phase 130) — on the
failed load collapses the figure (never a broken-image icon). live turn, after a page reload, and on the shared chat page; a failed
- **Image chips on zero-read turns.** A retrieved image document in the load collapses the figure (never a broken-image icon).
suggested tier is auto-cited: its source chip (with figure) appears - **Image citations on zero-read turns.** A retrieved image document in
even when the model never `read`s it — the model's seed block already the suggested tier is auto-cited: its figure (in the images row — no
carries the image's full description, so a real model has no reason to chip, phase 130) appears even when the model never `read`s it — the
read it (this applies with `BOR_IMAGES=1`, any `BOR_ANSWER_IMAGES`). 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 - **Off = byte-identical.** No fourth tool offered, no figure, no
`answer_image` key in any SSE frame or saved record. `answer_image` key in any SSE frame or saved record.
+208 -135
View File
@@ -466,21 +466,47 @@ const TUNE_ICON =
let tuneSeq = 0; // unique ids for one open tune form's inputs 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 /* The bubble's ACTIONS ROW (phase 130 task 02, TODO L6) — get-or-
sources' .msg-meta row when it exists (role=list → the button joins as create the .msg-meta.msg-actions row: the ONE row the answer
a listitem so ARIA stays valid); otherwise creates a plain meta row. actions (Tune, Retry, Save-as-doc) and the answer-status notes
Phase 16: anonymous visitors never get the button — this single guard (Stopped, Failed) join (ASSUMPTION A7 — the notes are answer-status
covers both fresh turns and the phase-14 restore path. */ 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) { function appendTuneButton(wrap) {
if (!isAdmin) return; // phase 16: tuning is admin-only if (!isAdmin) return; // phase 16: tuning is admin-only
const body = wrap.querySelector(".msg-body"); const meta = getActionsRow(wrap);
if (!body) return; if (!meta) return;
let meta = body.querySelector(".msg-meta");
if (!meta) {
meta = document.createElement("div");
meta.className = "msg-meta";
body.appendChild(meta);
}
if (meta.querySelector(".tune-btn")) return; // one per bubble if (meta.querySelector(".tune-btn")) return; // one per bubble
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.type = "button"; btn.type = "button";
@@ -491,24 +517,19 @@ function appendTuneButton(wrap) {
meta.appendChild(btn); meta.appendChild(btn);
} }
/* Phase 48 (owner-locked 2026-08-29): the "Stopped" note in the meta row /* Phase 48 (owner-locked 2026-08-29): the "Stopped" note in the
* of a user-stopped brain bubble. The live stop path (handleSend's stop * bubble's actions row (getActionsRow — phase 130 task 02, ASSUMPTION
* branch) and the phase-14 restore path (a record with `m.stopped`) share * A7: the notes are answer-status markers, the actions row is their
* this helper, so a restored bubble reads exactly like the stopped one. * home; the role=list row joins the span as a listitem so ARIA stays
* Reuses the .msg-meta row the way appendTuneButton does (role=list → the * valid). The live stop path (handleSend's stop branch) and the
* span joins as a listitem so ARIA stays valid). Non-interactive (no * phase-14 restore path (a record with `m.stopped`) share this helper,
* hover/focus — pointer-events: none in the CSS): the small filled-square * so a restored bubble reads exactly like the stopped one.
* stop glyph is aria-hidden decoration; the "Stopped" text carries the * Non-interactive (no hover/focus — pointer-events: none in the CSS):
* accessible meaning. */ * the small filled-square stop glyph is aria-hidden decoration; the
* "Stopped" text carries the accessible meaning. */
function appendStoppedNote(wrap) { function appendStoppedNote(wrap) {
const body = wrap?.querySelector?.(".msg-body"); const meta = getActionsRow(wrap);
if (!body) return; if (!meta) return;
let meta = body.querySelector(".msg-meta");
if (!meta) {
meta = document.createElement("div");
meta.className = "msg-meta";
body.appendChild(meta);
}
if (meta.querySelector(".stopped-note")) return; // one per bubble if (meta.querySelector(".stopped-note")) return; // one per bubble
const note = document.createElement("span"); const note = document.createElement("span");
note.className = "stopped-note"; note.className = "stopped-note";
@@ -527,25 +548,21 @@ function appendStoppedNote(wrap) {
* paths (finalizeFailedTurn / the zero-frame fallback) and the phase-14 * paths (finalizeFailedTurn / the zero-frame fallback) and the phase-14
* restore path (a record with `m.failed`) share this helper, so a restored * restore path (a record with `m.failed`) share this helper, so a restored
* bubble reads exactly like the failed one. The banner keeps its * bubble reads exactly like the failed one. The banner keeps its
* role="alert" summary; this in-bubble note is the refresh-surviving copy * role="alert" summary; this in-bubble note is the refresh-surviving
* (on restore it renders from the persisted `error` detail). Reuses the * copy (on restore it renders from the persisted `error` detail).
* .msg-meta row the way appendStoppedNote does (role=list → the span joins * Joins the bubble's actions row the way appendStoppedNote does
* as a listitem so ARIA stays valid); the triangle glyph is aria-hidden * (getActionsRow — phase 130 task 02, ASSUMPTION A7; role=list → the
* decoration — the "Failed" label + the detail text carry the accessible * span joins as a listitem so ARIA stays valid); the triangle glyph is
* meaning (text + color, never color alone — B5). `detail` goes through * aria-hidden decoration — the "Failed" label + the detail text carry
* textContent (no HTML from the error string, ever). */ * the accessible meaning (text + color, never color alone — B5).
* `detail` goes through textContent (no HTML from the error string,
* ever). */
const FAILED_ICON = const FAILED_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>'; '<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>';
function appendFailedNote(wrap, detail) { function appendFailedNote(wrap, detail) {
const body = wrap?.querySelector?.(".msg-body"); const meta = getActionsRow(wrap);
if (!body) return; if (!meta) return;
let meta = body.querySelector(".msg-meta");
if (!meta) {
meta = document.createElement("div");
meta.className = "msg-meta";
body.appendChild(meta);
}
if (meta.querySelector(".failed-note")) return; // one per bubble if (meta.querySelector(".failed-note")) return; // one per bubble
const note = document.createElement("span"); const note = document.createElement("span");
note.className = "failed-note"; note.className = "failed-note";
@@ -578,23 +595,17 @@ let lastBrainWrap = null;
const RETRY_ICON = const RETRY_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>'; '<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>';
/* "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 * action of phase 49 (owner-locked 2026-08-29, TODO.md L4). The house
* appendTuneButton pattern: reuses the .msg-meta row when it exists * pattern through getActionsRow (phase 130 task 02 — the role=list
* (role=list → the button joins as a listitem so ARIA stays valid), * row joins the button as a listitem so ARIA stays valid); one button
* otherwise creates a plain meta row; one button per bubble. NOT * per bubble. NOT admin-gated, unlike appendTuneButton — chat is
* admin-gated, unlike appendTuneButton — chat is public, so every * public, so every visitor gets the redo (the actions cluster reads as
* visitor gets the redo (the meta-row actions read as a pair: Tune for * a pair: Tune for the admin, Retry for everyone). Click →
* the admin, Retry for everyone). Click → retryLastTurn(wrap). */ * retryLastTurn(wrap). */
function appendRetryButton(wrap) { function appendRetryButton(wrap) {
const body = wrap.querySelector(".msg-body"); const meta = getActionsRow(wrap);
if (!body) return; if (!meta) return;
let meta = body.querySelector(".msg-meta");
if (!meta) {
meta = document.createElement("div");
meta.className = "msg-meta";
body.appendChild(meta);
}
if (meta.querySelector(".retry-btn")) return; // one per bubble if (meta.querySelector(".retry-btn")) return; // one per bubble
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.type = "button"; btn.type = "button";
@@ -608,12 +619,21 @@ function appendRetryButton(wrap) {
/* Last-bubble-only management (owner-locked 2026-08-29): the Retry /* Last-bubble-only management (owner-locked 2026-08-29): the Retry
* button lives on exactly ONE bubble — the last brain answer. Remove * button lives on exactly ONE bubble — the last brain answer. Remove
* every rendered .retry-btn FIRST (an earlier bubble's button is stale * 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 * the moment a newer answer lands), then re-append it to the last
* bubble — but only when that record has its preceding user record to * brain bubble — but only when that record has its preceding user
* re-ask (the invariant holds in practice: every brain record follows * record to re-ask (the invariant holds in practice: every brain
* its user record). Call sites: on `done`, on the empty-answer * record follows its user record). Phase 130 (task 02): the re-append
* fallback, on the stop finalize (a stopped partial is the prime retry * lands in the bubble's .msg-actions row (appendRetryButton →
* candidate), and once at the end of the phase-14 restore. * 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 * startNewChat needs no call: its list reset removes the buttons along
* with the list. */ * with the list. */
function markLastRetryable() { function markLastRetryable() {
@@ -629,13 +649,6 @@ function markLastRetryable() {
const prev = lastIdx > 0 ? conversation[lastIdx - 1] : null; const prev = lastIdx > 0 ? conversation[lastIdx - 1] : null;
if (!prev || prev.who !== "user") return; if (!prev || prev.who !== "user") return;
appendRetryButton(lastBrainWrap); 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 /* 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 * stays in the signature (the three call sites are unchanged) but no
* longer travels: phase 75 (TODO L4, A6) drafts the WHOLE conversation * longer travels: phase 75 (TODO L4, A6) drafts the WHOLE conversation
* from the `conversation` record at click time (buildSessionTranscript). * from the `conversation` record at click time (buildSessionTranscript).
* The .save-as-doc-btn's margin-inline-start: auto pushes it to the * Phase 130 (task 02): the button joins the bubble's .msg-actions row
* row's right edge (the TODO's "bottom right"); markLastRetryable * (getActionsRow) — the row's row-level right-align (phase 130 task
* keeps it rightmost when the last bubble also carries the Retry * 03) makes the WHOLE cluster the TODO's "bottom right" (the
* button. * 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 * Click: default title (phase 115 — the QUESTION THE ANSWER
* ANSWERED: the saved bubble's paired user bubble, whitespace- * ANSWERED: the saved bubble's paired user bubble, whitespace-
@@ -776,24 +792,18 @@ function buildSessionTranscript() {
return body.replace(/\s+$/, "") + "\n"; return body.replace(/\s+$/, "") + "\n";
} }
/* The bottom-right "Save as doc" button — the appendTuneButton /* The bottom-right "Save as doc" button — the house pattern through
* pattern: reuses the .msg-meta row when it exists (role=list → the * getActionsRow (phase 130 task 02: the role=list actions row joins
* button joins as a listitem so ARIA stays valid), otherwise creates * the button as a listitem so ARIA stays valid); one button per
* a plain meta row; one button per bubble. */ * bubble. */
function appendSaveAsDocButton(wrap, markdown) { function appendSaveAsDocButton(wrap, markdown) {
if (!isAdmin || !docsRepoConfigured) return; // phase 59: admin + configured if (!isAdmin || !docsRepoConfigured) return; // phase 59: admin + configured
const body = wrap.querySelector(".msg-body"); const meta = getActionsRow(wrap);
if (!body) return; if (!meta) return;
let meta = body.querySelector(".msg-meta");
if (!meta) {
meta = document.createElement("div");
meta.className = "msg-meta";
body.appendChild(meta);
}
if (meta.querySelector(".save-as-doc-btn")) return; // one per bubble if (meta.querySelector(".save-as-doc-btn")) return; // one per bubble
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.type = "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"); if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem");
btn.innerHTML = SAVE_AS_DOC_ICON + "<span>Save as doc</span>"; btn.innerHTML = SAVE_AS_DOC_ICON + "<span>Save as doc</span>";
btn.addEventListener("click", () => saveAsDoc(btn)); btn.addEventListener("click", () => saveAsDoc(btn));
@@ -1575,15 +1585,36 @@ async function readSSE(response, onEvent) {
/* Source chips (mono, source/path) under a Brain bubble. Phase 26: /* Source chips (mono, source/path) under a Brain bubble. Phase 26:
* clicking a chip opens the document in the same-page modal (no new * 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 * 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) { function appendSources(wrap, sources) {
if (!sources || !sources.length) return; if (!sources || !sources.length) return;
const body = wrap.querySelector(".msg-body"); const body = wrap.querySelector(".msg-body");
// 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"); const meta = document.createElement("div");
meta.className = "msg-meta"; meta.className = "msg-meta";
meta.setAttribute("role", "list"); meta.setAttribute("role", "list");
meta.setAttribute("aria-label", "Sources"); meta.setAttribute("aria-label", "Sources");
for (const s of sources) { for (const s of textRefs) {
const label = `${s.source}/${s.path}`; const label = `${s.source}/${s.path}`;
const chip = document.createElement("a"); const chip = document.createElement("a");
chip.className = "source-chip"; chip.className = "source-chip";
@@ -1597,16 +1628,24 @@ function appendSources(wrap, sources) {
chip.textContent = label; chip.textContent = label;
chip.title = label; chip.title = label;
meta.appendChild(chip); 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);
} }
body.appendChild(meta); body.appendChild(meta);
// Accessible full path whenever the pill visually truncates. // Accessible full path whenever the pill visually truncates
// (the chips row only — the images row carries no chips).
for (const chip of meta.children) { for (const chip of meta.children) {
if (chip.scrollWidth > chip.clientWidth) chip.setAttribute("aria-label", chip.title); 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);
}
} }
/* Phase 113 (task 02): the DE-EMPHASIZED related-docs row — the done /* Phase 113 (task 02): the DE-EMPHASIZED related-docs row — the done
@@ -1617,12 +1656,14 @@ function appendSources(wrap, sources) {
* like the chips — the same documentUrl href (the /document.html escape * like the chips — the same documentUrl href (the /document.html escape
* hatch) and the same left-click → same-page modal (phase 26). The * hatch) and the same left-click → same-page modal (phase 26). The
* labeled row ("Nearby docs, in case:") joins the bubble's .msg-meta * 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. * family and stacks below the citation rows in the .msg-body flex gap.
* app.js appends it LAST among the meta rows — after appendTuneButton / * app.js appends it BELOW the citation clusters and ABOVE the actions
* appendSaveAsDocButton / appendRetryButton claimed the FIRST * row (phase 130 task 02 — the .msg-actions row is the bubble's LAST
* .msg-meta row — so a deflected turn (no citation row) never lets a * row, and the getActionsRow scoping keeps the buttons out of this row
* meta action join this row. Empty/absent input → no DOM at all * either way: a deflected turn with related docs gets its OWN actions
* (pre-phase saved chats carry no `related` — the row is simply absent). * 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) { function appendRelated(wrap, related) {
if (!related || !related.length) return; if (!related || !related.length) return;
@@ -1669,9 +1710,12 @@ function appendRelated(wrap, related) {
* fetches the content endpoint the chip's modal already uses (the * fetches the content endpoint the chip's modal already uses (the
* (source, path) pair is the same lookup key) and swaps the summary * (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 * in; until it settles — and when it fails — the title stands in (a
* caption is ALWAYS visible). A FAILED IMAGE LOAD collapses the figure * caption is ALWAYS visible). A FAILED IMAGE LOAD becomes the IN-PLACE
* to the plain chip (the figure is removed — never a broken-image * CHIP FALLBACK (phase 130 task 01, ASSUMPTION A3): the figure is
* icon: the row's bytes route 404s when the copy was lost). */ * 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) { function appendSourceImageFigure(meta, s) {
const label = `${s.source}/${s.path}`; const label = `${s.source}/${s.path}`;
const fig = document.createElement("a"); const fig = document.createElement("a");
@@ -1690,9 +1734,25 @@ function appendSourceImageFigure(meta, s) {
img.src = s.image_url; img.src = s.image_url;
img.alt = s.title || label; // the summary arrives via the fetch below img.alt = s.title || label; // the summary arrives via the fetch below
img.addEventListener("error", () => { img.addEventListener("error", () => {
// The plain chip stays (it is the collapse target) — the figure, // Phase 130 (task 01, ASSUMPTION A3): the IN-PLACE CHIP FALLBACK —
// with its not-yet-resolved alt, goes. // there is no chip for image docs anymore (the figure is the
fig.remove(); // 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"); const caption = document.createElement("span");
caption.className = "source-image-caption"; caption.className = "source-image-caption";
@@ -1701,7 +1761,7 @@ function appendSourceImageFigure(meta, s) {
meta.appendChild(fig); meta.appendChild(fig);
// …and the document's summary once the content fetch settles. // …and the document's summary once the content fetch settles.
fetchContentSummary(s.source, s.path).then((summary) => { 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() !== "") { if (summary && summary.trim() !== "") {
img.alt = summary; img.alt = summary;
caption.textContent = 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 /* 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, * 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 * 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 * ABOVE the source clusters — the callers append it right BEFORE
* appendSources creates the chip row, so it lands in between. The * appendSources builds the rows (phase 130 task 01: the chips row,
* text bubble is untouched: the figure is ADDITIVE ("summaries * then the images row), so it lands in between. The text bubble is
* should still be provided"). * untouched: the figure is ADDITIVE ("summaries should still be
* provided").
* *
* The visual sibling of appendSourceImageFigure (the compact chip * The visual sibling of appendSourceImageFigure (the compact chip
* figure) but a different surface: the answer figure is the answer's * 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 // Phase 124 (task 06, TODO L3): the answer image restores with the
// bubble — the SAME helper + the SAME A4 position as the live done // bubble — the SAME helper + the SAME A4 position as the live done
// path (below the text, above the chip row). A record without the // path (below the text, above the source clusters — phase 130:
// key renders byte-identically (pre-phase chats restore unchanged). // 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); if (m.answer_image) appendAnswerImage(wrap, m.answer_image);
appendSources(wrap, m.sources); 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 // 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 // Tune (the "note, not an answer" scope the m.stopped exclusion
// below shares; the live failure paths add no Tune either). Stopped // 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 // precedes the failed record, unchanged; a mid-conversation failed
// bubble gets no button — the phase-49 last-bubble-only rule). // bubble gets no button — the phase-49 last-bubble-only rule).
if (m.failed && m.error) appendFailedNote(wrap, m.error); 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 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 // Phase 124 (task 06, TODO L3): the LLM's answer image — the
// answer's first-class figure (A4): appended to .msg-body // answer's first-class figure (A4): appended to .msg-body
// right BEFORE the chip row, so it lands below the answer // right BEFORE the source clusters (phase 130: the chips row,
// text and above the sources. The text is untouched (the // 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"); // figure is additive — "summaries should still be provided");
// a frame without it renders byte-identically (no figure DOM). // a frame without it renders byte-identically (no figure DOM).
if (ev.answer_image) appendAnswerImage(wrap, ev.answer_image); 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 // surface (the chips above); the related tier (scored docs
// under the usefulness bar — and the weak hits of a DEFLECTED // under the usefulness bar — and the weak hits of a DEFLECTED
// turn, whose `ev.sources` is empty → zero chips) renders as // turn, whose `ev.sources` is empty → zero chips) renders as
// the de-emphasized labeled row, appended LAST among the meta // the de-emphasized labeled row BELOW the citation clusters
// rows below (after the appendTuneButton/SaveAsDoc/Retry // and ABOVE the actions row (phase 130 task 02 — the
// claimers took the first .msg-meta row, so an action never // .msg-actions row is the bubble's LAST row, so the action
// joins the related row — a deflected turn with related docs // appenders after this get-or-create it; the getActionsRow
// still gets its own meta row for the buttons). // 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 // Thinking-without-answer (reasoning can exhaust max_tokens): the
// bubble gets the empty-answer fallback — what the user saw is // bubble gets the empty-answer fallback — what the user saw is
// what gets persisted. // what gets persisted.
const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : ""); 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) { if (!acc && sawThinking) {
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText); wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText);
} }
@@ -3120,8 +3184,17 @@ async function runTurn(text, { reask = false, image = null } = {}) {
suggestions: ev.suggestions, suggestions: ev.suggestions,
}, leavePartialIndex); }, leavePartialIndex);
lastBrainWrap = wrap; // this bubble is now the last brain answer 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 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") { } else if (ev.type === "tool_result") {
// Phase 95 (A15 extension, task 02): the truncation the LLM is // Phase 95 (A15 extension, task 02): the truncation the LLM is
// told about is told to the USER. One frame per truncated read, // 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 nothing = "The model answered with nothing.";
const fwrap = addMessage("brain", fallback); const fwrap = addMessage("brain", fallback);
appendTuneButton(fwrap); appendTuneButton(fwrap);
appendSaveAsDocButton(fwrap, fallback); // phase 59: parity with the done path
appendFailedNote(fwrap, nothing); appendFailedNote(fwrap, nothing);
// Phase 73: correlated like the other settles — unreachable when a // Phase 73: correlated like the other settles — unreachable when a
// pagehide partial exists (that needs acc, which means a wrap), but // 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 }, leavePartialIndex); // persist what the user actually saw
lastBrainWrap = fwrap; lastBrainWrap = fwrap;
markLastRetryable(); // phase 49: the fallback bubble is retryable too markLastRetryable(); // phase 49: the fallback bubble is retryable too
appendSaveAsDocButton(fwrap, fallback); // phase 59: parity with the done path
} }
} catch (err) { } catch (err) {
if (aborted) { if (aborted) {
+38 -13
View File
@@ -752,6 +752,29 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
gap: 0.35rem; gap: 0.35rem;
align-items: center; 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 { .source-chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -918,8 +941,10 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
padding-inline: 0.25rem; padding-inline: 0.25rem;
} }
/* ---------- Steering notes (phase 15) ---------- */ /* ---------- Steering notes (phase 15) ---------- */
/* "Tune" button in the meta row of every completed brain bubble: ghost /* "Tune" button in the completed brain bubble's actions row
pill, ≥44px, right-aligned after the source chips. ink-soft on surface (.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. */ ≈6.9:1; hover pair brand-ink/brand-soft ≈6.9:1. */
.tune-btn { .tune-btn {
display: inline-flex; display: inline-flex;
@@ -927,7 +952,6 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
justify-content: center; justify-content: center;
gap: 0.35rem; gap: 0.35rem;
min-height: 44px; min-height: 44px;
margin-left: auto;
padding: 0.35rem 0.8rem; padding: 0.35rem 0.8rem;
border-radius: 999px; border-radius: 999px;
border: 1px solid var(--line); 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); } .tune-btn:hover { background: var(--brand-soft); color: var(--brand-ink); }
/* Phase 49 (owner-locked 2026-08-29, TODO.md L4): the "Retry" button — /* 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 redo-in-place action in the actions row (.msg-meta.msg-actions —
The exact visual family of .tune-btn (same pill size/spacing, the phase 130) of the LAST brain bubble. The exact visual family of
global :focus-visible ring, >=44px via min-height) so the two meta .tune-btn (same pill size/spacing, the global :focus-visible ring,
actions read as a pair — but neutral: ink-soft -> ink on hover (Tune >=44px via min-height) so the meta actions read as a set — but
keeps the brand pair). The redo glyph rides currentColor. Contrast: 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 ink-soft on --bg ~8.6:1, ink ~15:1, hover ink on --brand-soft
~15.7:1 — all AA. */ ~15.7:1 — all AA. */
.retry-btn { .retry-btn {
@@ -956,7 +982,6 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
justify-content: center; justify-content: center;
gap: 0.35rem; gap: 0.35rem;
min-height: 44px; min-height: 44px;
margin-left: auto;
padding: 0.35rem 0.8rem; padding: 0.35rem 0.8rem;
border-radius: 999px; border-radius: 999px;
border: 1px solid var(--line); 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 (same pill size/spacing, the global :focus-visible ring, >=44px via
min-height) so the meta actions read as one set — the brand hover min-height) so the meta actions read as one set — the brand hover
pair like Tune (a docs action), the file glyph rides currentColor. pair like Tune (a docs action), the file glyph rides currentColor.
margin-inline-start: auto pushes it to the row's RIGHT edge (the No per-button auto margin (phase 130 — the row's .msg-actions
TODO's "bottom right"; markLastRetryable keeps it rightmost when justify right-aligns the WHOLE cluster to the row's RIGHT edge, the
the last bubble also carries the Retry button). Contrast: 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, ink-soft on --bg ~8.6:1, hover brand-ink on --brand-soft — AA,
same as the family. */ same as the family. */
.save-as-doc-btn { .save-as-doc-btn {
@@ -989,7 +1015,6 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
justify-content: center; justify-content: center;
gap: 0.35rem; gap: 0.35rem;
min-height: 44px; min-height: 44px;
margin-inline-start: auto;
padding: 0.35rem 0.8rem; padding: 0.35rem 0.8rem;
border-radius: 999px; border-radius: 999px;
border: 1px solid var(--line); border: 1px solid var(--line);
+75 -57
View File
@@ -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 turn, the shape that makes the chip assertion meaningful). The
brain bubble shows the ANSWER FIGURE (``.answer-image`` — ``<img>`` brain bubble shows the ANSWER FIGURE (``.answer-image`` — ``<img>``
from the bytes route, ``loading="lazy"``, below the text and above from the bytes route, ``loading="lazy"``, below the text and above
the chip row per A4, alt/caption settling to the summary), the the source clusters per A4, alt/caption settling to the summary),
answer TEXT is still streamed in full (the tail echo of the the answer TEXT is still streamed in full (the tail echo of the
``<documents>`` seed proves the description was in the prompt), the ``<documents>`` seed proves the description was in the prompt), the
source-chip row carries the image doc's chip + its phase-122 sources block carries the image doc's citation as the compact
figure (the AUTO-CITE proof — the mock made zero ``read`` calls, figure in the images row (``.msg-meta.msg-images`` — phase 130
asserted on the mock's request capture), and the "Nearby docs" row task 01: NO chip for an image doc, the figure IS the citation;
does not repeat the doc. The wire pin: the first model request the AUTO-CITE proof — the mock made zero ``read`` calls, asserted
offered FOUR tools (the three base + ``show_image``) and the turn on the mock's request capture), and the "Nearby docs" row does not
executed exactly one ``show_image`` — the saved brain record repeat the doc. The wire pin: the first model request offered
persists the ``answer_image`` ref. 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: - **Restore + share** — a second feature turn, then a page RELOAD:
the restored brain bubble shows the figure again (the the restored brain bubble shows the figure again (the
``answer_image`` persistence through the ``bor.chat.v1`` restore ``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. fresh anonymous context) renders the figure on the shared page.
- **Bug-fix-only flow** — the ``which diagram is in your notes`` - **Bug-fix-only flow** — the ``which diagram is in your notes``
trigger (the mock answers straight from the seed with ZERO tool trigger (the mock answers straight from the seed with ZERO tool
calls — no ``show_image``, no ``read``): the source-chip row STILL calls — no ``show_image``, no ``read``): the sources block STILL
carries the image doc's chip + figure (the bug fix on the pure carries the image doc's citation figure (no chip — phase 130;
zero-read path — the assertion the owner's report describes), the the bug fix on the pure zero-read path — the assertion the owner's
answer text quotes the seeded description's tail, and NO answer report describes), the answer text quotes the seeded
figure renders (the model never called the tool). description's tail, and NO answer figure renders (the model never
called the tool).
* **App B (default off)** — ``BOR_IMAGES=true``, ``BOR_ANSWER_IMAGES`` * **App B (default off)** — ``BOR_IMAGES=true``, ``BOR_ANSWER_IMAGES``
pinned to the code default ``false`` (EXPLICITLY — process env ranks pinned to the code default ``false`` (EXPLICITLY — process env ranks
above an operator's local gitignored ``.env``, so the default-off 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 ``show_image`` among the offered tools (the request capture carries
exactly the THREE base tools), the brain bubble has NO answer exactly the THREE base tools), the brain bubble has NO answer
figure, and the saved brain record carries NO ``answer_image`` key figure, and the saved brain record carries NO ``answer_image`` key
(the byte-identical-off contract). The bug-fix trigger STILL chips (the byte-identical-off contract). The bug-fix trigger STILL cites
the image doc (the chips fix is NOT gated by ``BOR_ANSWER_IMAGES`` — the image doc (the figure, no chip — phase 130; the auto-cite fix is
it belongs to the ``BOR_IMAGES``-indexed docs, per the phase design). 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 DB isolation: the suites share one Postgres; ``saved_chats`` survives
the truncation (the house pattern), so every question text here is the truncation (the house pattern), so every question text here is
@@ -593,15 +597,18 @@ def _assert_answer_figure(
def _assert_image_chip( def _assert_image_chip(
page: Page, seeded: dict[str, str], *, last_brain: str = ".msg.brain" page: Page, seeded: dict[str, str], *, last_brain: str = ".msg.brain"
) -> None: ) -> None:
"""The citation chip the AUTO-CITE earns (phase 124 task 05): the """The AUTO-CITE (phase 124 task 05) in the phase-130 cluster
image doc's text chip + its phase-122 compact figure (``<img>`` shape (task 01, TODO L6): an image doc gets NO chip — its figure
from the bytes route, alt settling to the summary — the admin IS the citation — the compact figure (``<img>`` from the bytes
surface; the shared page's chip is plain text and asserted route, alt settling to the summary — the admin surface; the
separately).""" 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") chip = page.locator(f"{last_brain} .source-chip")
expect(chip).to_have_count(1) expect(chip).to_have_count(0)
expect(chip).to_have_text(f"{SOURCE_NAME}/{DOC_PATH}") row = page.locator(f"{last_brain} .msg-meta.msg-images")
fig = page.locator(f"{last_brain} .source-image") 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) expect(fig).to_have_count(1)
img = fig.locator(".source-image-img") img = fig.locator(".source-image-img")
expect(img).to_be_visible(timeout=15_000) expect(img).to_be_visible(timeout=15_000)
@@ -612,7 +619,7 @@ def _assert_image_chip(
def _body_child_classes(page: Page) -> list[str]: def _body_child_classes(page: Page) -> list[str]:
"""The LAST brain bubble's ``.msg-body`` direct children's class """The LAST brain bubble's ``.msg-body`` direct children's class
names, in DOM order — the A4 position pin (bubble < answer-image names, in DOM order — the A4 position pin (bubble < answer-image
< msg-meta).""" < the first .msg-meta-family source row)."""
return page.evaluate( return page.evaluate(
"""() => { """() => {
const body = [...document.querySelectorAll(".msg.brain")].pop() 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: a ``show_image`` call (NO ``read`` — the zero-read turn). Asserts:
(1) the brain bubble's ``.msg-body`` carries ``.answer-image`` with (1) the brain bubble's ``.msg-body`` carries ``.answer-image`` with
an ``<img>`` from the bytes route (200, image Content-Type) and an ``<img>`` from the bytes route (200, image Content-Type) and
``loading="lazy"``; (2) the figure sits BEFORE the ``.msg-meta`` ``loading="lazy"``; (2) the figure sits BELOW the answer text and
source-chip row (A4); (3) the answer TEXT is non-empty and quotes ABOVE the source clusters (A4 — the image-only KB renders the
the seed's tail (the "summaries still provided" contract + the images row, ``.msg-meta.msg-images`` — phase 130 task 01: no chip
echo proving the seed was in the prompt); (4) the source-chip row row); (3) the answer TEXT is non-empty and quotes the seed's tail
carries the image doc's chip + its figure (the AUTO-CITE proof — (the "summaries still provided" contract + the echo proving the
the mock made ZERO ``read`` calls: only ``show_image`` — asserted seed was in the prompt); (4) the images row carries the image
on the wire); (5) the "Nearby docs" row does NOT repeat the doc. doc's figure — no chip (phase 130: the figure IS the citation) —
Plus the persistence pin: the saved brain record carries the the AUTO-CITE proof (the mock made ZERO ``read`` calls: only
``answer_image`` ref (the task-04 frame field, round-tripped).""" ``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="/") login(page, app_url, next="/")
expect(page.locator("#kb-banner")).to_be_hidden() 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). # summary (the fetchContentSummary pattern settles both).
_assert_answer_figure(page, app_url, seeded, caption=seeded["description"]) _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) classes = _body_child_classes(page)
assert "bubble" in classes, f"no answer bubble in .msg-body: {classes}" 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 "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 ( assert (
classes.index("bubble") classes.index("bubble")
< classes.index("answer-image") < classes.index("answer-image")
< classes.index("msg-meta") < meta_idx
), f"A4 order violated (bubble < answer-image < msg-meta): {classes}" ), f"A4 order violated (bubble < answer-image < source row): {classes}"
# (4) The chip row carries the image doc's chip + its figure — # (4) The images row carries the image doc's figure (no chip —
# the AUTO-CITE (the model never read it; the seed IS its # phase 130) — the AUTO-CITE (the model never read it; the
# entire content, so suggested == read for it). # seed IS its entire content, so suggested == read for it).
_assert_image_chip(page, seeded) _assert_image_chip(page, seeded)
# (5) The "Nearby docs" row does NOT repeat the image doc — the # (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 # 3. App A — the bug-fix-only flow: ZERO tool calls, the citation
# appears (the pure zero-read path — the owner's report) # 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 """The ``which diagram is in your notes`` trigger: the mock
answers straight from the seed with ZERO tool calls (no answers straight from the seed with ZERO tool calls (no
``show_image`` — the clause's flow is not triggered — and no ``show_image`` — the clause's flow is not triggered — and no
``read``): the source-chip row STILL carries the image doc's chip ``read``): the sources block STILL carries the image doc's
+ figure (the bug fix on the pure zero-read path — the assertion citation figure (no chip — phase 130; the bug fix on the pure
the owner's report describes), the answer text quotes the seeded zero-read path — the assertion the owner's report describes), the
description's tail (the description WAS in the prompt), and NO answer text quotes the seeded description's tail (the description
answer figure renders (the model never called the tool).""" WAS in the prompt), and NO answer figure renders (the model never
called the tool)."""
login(page, app_url, next="/") login(page, app_url, next="/")
expect(page.locator("#kb-banner")).to_be_hidden() 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) expect(page.locator(".msg.brain .tool-calls-disclosure")).to_have_count(0)
# NO answer figure (the model never called show_image) — but the # NO answer figure (the model never called show_image) — but the
# chip row STILL carries the image doc's chip + figure (the auto- # sources block STILL carries the image doc's citation figure
# cite: suggested == read for an image doc, no read performed). # (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) expect(page.locator(".msg.brain .answer-image")).to_have_count(0)
_assert_image_chip(page, seeded) _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 # 5. App B (default off) — the bug-fix trigger STILL cites the image
# doc (the chips fix belongs to BOR_IMAGES, not BOR_ANSWER_IMAGES) # 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: ) -> None:
"""The bug-fix trigger on the default-OFF app (the clause is """The bug-fix trigger on the default-OFF app (the clause is
absent — the trigger does NOT require it): ZERO tool calls, the absent — the trigger does NOT require it): ZERO tool calls, the
answer quotes the seeded description's tail, and the source-chip answer quotes the seeded description's tail, and the sources
row STILL carries the image doc's chip + figure — the chips fix block STILL carries the image doc's citation figure (no chip —
is NOT gated by ``BOR_ANSWER_IMAGES`` (it belongs to the phase 130) — the auto-cite fix is NOT gated by
``BOR_IMAGES``-indexed docs, per the phase design).""" ``BOR_ANSWER_IMAGES`` (it belongs to the ``BOR_IMAGES``-indexed
docs, per the phase design)."""
login(page, default_app_url, next="/") login(page, default_app_url, next="/")
expect(page.locator("#kb-banner")).to_be_hidden() expect(page.locator("#kb-banner")).to_be_hidden()
+939
View File
@@ -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 ``<tools>`` 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 ``<tools>`` 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, ``<img>`` 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 ``<img>`` 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
# <img> 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)
+15 -12
View File
@@ -413,12 +413,13 @@ def test_chat_sources_block_shows_the_inline_image(
turn chips nothing): the mock's echo carries the agent ``read`` turn chips nothing): the mock's echo carries the agent ``read``
result VERBATIM — including the task-05 ``IMAGE_DOC_MARKER`` line result VERBATIM — including the task-05 ``IMAGE_DOC_MARKER`` line
(the model saw the description, not raw bytes) — and the answer's (the model saw the description, not raw bytes) — and the answer's
sources block carries the citation chip (the READ doc clears the sources block carries the citation (the READ doc clears the
usefulness floor) AND the compact inline figure — the ``<img>`` usefulness floor) as the compact figure in the images row
from the bytes route, the visible caption + ``alt`` settling to (phase 130 task 01: NO chip for an image doc — the figure IS the
the document's summary (the figure fetches it from the content citation) — the ``<img>`` from the bytes route, the visible
endpoint — the frame carries no summary), the "shown in the chat caption + ``alt`` settling to the document's summary (the figure
nicely" contract.""" fetches it from the content endpoint — the frame carries no
summary), the "shown in the chat nicely" contract."""
login(page, app_url, next="/") login(page, app_url, next="/")
expect(page.locator("#kb-banner")).to_be_hidden() 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-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send") expect(page.locator("#send-label")).to_have_text("Send")
# The citation chip (the text affordance stays — the figure is # Phase 130 (task 01, TODO L6): NO chip for the image doc — its
# additive, not a replacement). # figure is the citation — and the figure sits in its own images
chip = page.locator(".msg.brain .source-chip") # cluster (.msg-meta.msg-images), not interleaved with a chip.
expect(chip).to_have_count(1) expect(page.locator(".msg.brain .source-chip")).to_have_count(0)
expect(chip).to_have_text(f"{SOURCE_NAME}/{DOC_PATH}") 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 inline figure: image + caption, both settling to the summary
# (the content fetch resolves the alt/caption after the title). # (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) expect(fig).to_have_count(1)
img = fig.locator(".source-image-img") img = fig.locator(".source-image-img")
expect(img).to_be_visible(timeout=15_000) expect(img).to_be_visible(timeout=15_000)
+17 -10
View File
@@ -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 — PDF (phase 119 A1): the echo carries the read result VERBATIM —
the document's FULL extracted text (LOCKED A4 — the page markers the document's FULL extracted text (LOCKED A4 — the page markers
and both page texts reach the model and land in the answer), and 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) the sources block renders the mixed citation as phase-130
PLUS the embedded image's AUTO-cited chip (phase 124 — the clusters (task 01): the chips row carries the PDF's citation chip
suggested-tier image doc) with its compact inline figure (the (the READ doc) and NO chip for the image doc (its figure IS the
bytes route, caption/alt settling to the description).""" 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="/") login(page, app_url, next="/")
expect(page.locator("#kb-banner")).to_be_hidden() 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-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send") expect(page.locator("#send-label")).to_have_text("Send")
# The citation chips: the READ PDF first (read order), then the # Phase 130 (task 01) — the mixed citation: the chips row carries
# suggested image doc's auto-cite (phase 124). # 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") 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(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 # settling to the description (the content fetch resolves the
# summary after the title). # summary after the title).
fig = page.locator(".msg.brain .source-image") fig = row.locator(".source-image")
expect(fig).to_have_count(1) expect(fig).to_have_count(1)
img = fig.locator(".source-image-img") img = fig.locator(".source-image-img")
expect(img).to_be_visible(timeout=15_000) expect(img).to_be_visible(timeout=15_000)
+7 -3
View File
@@ -429,7 +429,10 @@ def test_a11y_landmarks_and_labels(
f"skip link must move focus to #main ({path})" 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( unnamed = page.evaluate(
"""() => [...document.querySelectorAll("button")] """() => [...document.querySelectorAll("button")]
.filter((b) => !(b.getAttribute("aria-label") || b.textContent.trim())) .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}" assert unnamed == 0, f"{unnamed} button(s) without an accessible name on {path}"
bad_imgs = page.evaluate( 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} <img> without alt on {path}" assert bad_imgs == 0, f"{bad_imgs} <img> without an alt attribute on {path}"
# A real keyboard Tab walk: every focused element shows a visible # A real keyboard Tab walk: every focused element shows a visible
# :focus-visible outline (solid, >= 2px). Start from the top of the # :focus-visible outline (solid, >= 2px). Start from the top of the
+421
View File
@@ -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)"
)
+245
View File
@@ -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 ``<selector> { … }`` 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 (<query>)`` 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
+7 -3
View File
@@ -84,12 +84,16 @@ def _fn_body(js: str, name: str) -> str:
def test_append_failed_note_exists_and_guards_dedup() -> None: def test_append_failed_note_exists_and_guards_dedup() -> None:
"""``appendFailedNote(wrap, detail)`` exists and mirrors """``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).""" most ONE ``.failed-note`` per bubble (the duplicate guard)."""
js = _js() js = _js()
body = _fn_body(js, "appendFailedNote") body = _fn_body(js, "appendFailedNote")
assert '.msg-body' in body, "the note must land in the bubble's .msg-body" assert "getActionsRow(wrap)" in body, (
assert ".msg-meta" in body, "the note rides the existing .msg-meta row" "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 '.failed-note' in body
assert ( assert (
'if (meta.querySelector(".failed-note")) return;' in body 'if (meta.querySelector(".failed-note")) return;' in body
+35 -23
View File
@@ -313,18 +313,22 @@ def test_stop_branch_keeps_partial_and_persists_stopped() -> None:
def test_stopped_note_helper_and_restore_path() -> None: def test_stopped_note_helper_and_restore_path() -> None:
"""appendStoppedNote: reuses/creates the .msg-meta row exactly like """appendStoppedNote: joins the bubble's ACTIONS row (getActionsRow
appendTuneButton (role=list → the span joins as a listitem), one — phase 130 task 02, ASSUMPTION A7: the notes are answer-status
.stopped-note per bubble — the aria-hidden stop-glyph SVG + the markers, the actions row is their home; the role=list row joins the
"Stopped" text (the accessible meaning). The restore path renders it span as a listitem so ARIA stays valid), one .stopped-note per
for records with `m.stopped` (phase-14 optional-field convention — bubble — the aria-hidden stop-glyph SVG + the "Stopped" text (the
no version bump).""" accessible meaning). The restore path renders it for records with
`m.stopped` (phase-14 optional-field convention — no version
bump)."""
js = _js() js = _js()
fn = js.find("function appendStoppedNote") fn = js.find("function appendStoppedNote")
assert fn != -1, "appendStoppedNote must exist" assert fn != -1, "appendStoppedNote must exist"
body = js[fn : js.find("\n}\n", fn)] body = js[fn : js.find("\n}\n", fn)]
assert 'querySelector(".msg-meta")' in body, "reuses the meta row when it exists" assert "getActionsRow(wrap)" in body, (
assert 'className = "msg-meta"' in body, "creates it otherwise" "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 'className = "stopped-note"' in body
assert 'querySelector(".stopped-note")' in body, "one note per bubble" assert 'querySelector(".stopped-note")' in body, "one note per bubble"
assert 'note.setAttribute("role", "listitem")' in body 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: def test_retry_button_is_not_admin_gated_and_one_per_bubble() -> None:
"""appendRetryButton: the house appendTuneButton pattern — reuses the """appendRetryButton: the house pattern through getActionsRow
.msg-meta row when it exists (role=list → the button joins as a (phase 130 task 02 — the button joins the bubble's dedicated
listitem so ARIA stays valid), creates it otherwise, one .retry-btn .msg-actions row; the role=list row joins it as a listitem so ARIA
per bubble, the aria-hidden redo glyph + the "Retry" text (the stays valid), one .retry-btn per bubble, the aria-hidden redo glyph
accessible name). NOT admin-gated — unlike appendTuneButton, chat is + the "Retry" text (the accessible name). NOT admin-gated — unlike
public and every visitor gets the redo (owner-locked).""" appendTuneButton, chat is public and every visitor gets the redo
(owner-locked)."""
js = _js() js = _js()
fn = js.find("function appendRetryButton") fn = js.find("function appendRetryButton")
assert fn != -1, "appendRetryButton must exist" assert fn != -1, "appendRetryButton must exist"
body = js[fn: js.find("\n}\n", fn)] body = js[fn: js.find("\n}\n", fn)]
assert "isAdmin" not in body, "Retry is NOT admin-gated (owner-locked: all visitors)" 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 "getActionsRow(wrap)" in body, (
assert 'className = "msg-meta"' in body, "creates it otherwise" "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 'className = "retry-btn"' in body
assert 'querySelector(".retry-btn")' in body, "one Retry button per bubble" assert 'querySelector(".retry-btn")' in body, "one Retry button per bubble"
assert 'btn.setAttribute("role", "listitem")' in body, ("role=list → listitem") 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: def test_retry_btn_css_is_the_tune_family() -> None:
"""Phase 49 styling: the .retry-btn pill is the exact .tune-btn """Phase 49 styling: the .retry-btn pill is the exact .tune-btn
visual family (same size/spacing/min-height, right-aligned after visual family (same size/spacing/min-height, 14px glyph) so the
the source chips, 14px glyph) so the two meta actions read as a meta actions read as a pair — with the neutral ink-soft → ink
pair — with the neutral ink-soft → ink hover (Tune keeps the brand hover (Tune keeps the brand pair). The right-align moved to the
pair). Contrast: ink-soft on --bg ~8.6:1, ink ~15:1, hover ink on ROW in phase 130 (the .msg-meta.msg-actions justify — the button
--brand-soft ~15.7:1 — all AA. The mobile squeeze keeps the >=44px is a plain flex item with NO per-button auto margin; the
floor.""" 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() css = _css()
block = re.search(r"\.retry-btn \{([\s\S]*?)\n\}", css) block = re.search(r"\.retry-btn \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .retry-btn" 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 ( for prop in (
"display: inline-flex", "display: inline-flex",
"min-height: 44px", "min-height: 44px",
"margin-left: auto",
"padding: 0.35rem 0.8rem", "padding: 0.35rem 0.8rem",
"border-radius: 999px", "border-radius: 999px",
"border: 1px solid var(--line)", "border: 1px solid var(--line)",
@@ -606,6 +614,10 @@ def test_retry_btn_css_is_the_tune_family() -> None:
"cursor: pointer", "cursor: pointer",
): ):
assert prop in body, f".retry-btn must keep the .tune-btn family ({prop})" 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), ( assert re.search(r"\.retry-btn svg \{ width: 14px; height: 14px", css), (
"the redo glyph rides the 14px meta-row size" "the redo glyph rides the 14px meta-row size"
) )
+94 -21
View File
@@ -51,12 +51,19 @@ image doc prefacing the description with the pinned
``IMAGE_DOC_MARKER`` line (header + date line byte-identical, the ``IMAGE_DOC_MARKER`` line (header + date line byte-identical, the
cap-truncation case marker-before-cut, a text doc's result cap-truncation case marker-before-cut, a text doc's result
byte-identical); and the house-style frontend source contracts — the byte-identical); and the house-style frontend source contracts — the
chat sources block's inline image figure (both the citation chips and chat sources block's inline image figure reads ``image_url``, sets
the related row, gated on ``s.image_url``) reads ``image_url``, sets
``alt`` + the visible caption to the document's summary (fetched from ``alt`` + the visible caption to the document's summary (fetched from
the content endpoint the chip's modal already uses — the frame the content endpoint the chip's modal already uses — the frame
carries no summary), and collapses to the plain chip on an image carries no summary), and becomes the IN-PLACE CHIP FALLBACK on an
load error (never a broken-image icon). The SSE-level byte pin sits in 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 ``tests/integration/test_chat_api.py``; the E2E rendering scenario is
task 06's isolated story suite. 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) # --- 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 — """House-style source contract (app.js appendSourceImageFigure —
task 05's "shown in the chat nicely" renderer): the figure's task 05's "shown in the chat nicely" renderer): the figure's
``<img>`` comes from the ref's ``image_url``; ``alt`` is set ``<img>`` comes from the ref's ``image_url``; ``alt`` is set
(title first, then the SUMMARY once the content fetch settles — (title first, then the SUMMARY once the content fetch settles —
the frame carries no summary, image_url is its only new field) the frame carries no summary, image_url is its only new field)
and so is the VISIBLE caption; a FAILED image load removes the and so is the VISIBLE caption. Phase 130 (task 01, ASSUMPTION A3)
whole figure — it collapses to the plain chip, never a REPLACES the old collapse-to-chip rule (``fig.remove()`` — the
broken-image icon. Properties only (src/alt/textContent) — the chip stayed) with the IN-PLACE CHIP FALLBACK: a FAILED image load
figure builds no innerHTML from document-derived data.""" 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") js = APP_JS.read_text(encoding="utf-8")
body = _function_body(js, "function appendSourceImageFigure(meta, s) {") body = _function_body(js, "function appendSourceImageFigure(meta, s) {")
assert "img.src = s.image_url" in body, "the <img> must come from s.image_url" assert "img.src = s.image_url" in body, "the <img> 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 = s.title || label" in body # visible caption: title
assert "caption.textContent = summary" in body # …then the summary assert "caption.textContent = summary" in body # …then the summary
assert 'img.addEventListener("error"' in body, "the error handler must exist" 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, ( assert "fetchContentSummary(s.source, s.path)" in body, (
"the frame carries no summary — the figure fetches it" "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)) code = re.sub(r"//.*", "", re.sub(r"/\*.*?\*/", "", body, flags=re.S))
assert "innerHTML" not in code, "no innerHTML from document-derived data" assert "innerHTML" not in code, "no innerHTML from document-derived data"
# The figure reuses the chip's navigation (phase 26 contract): # 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 assert "openDocumentModal(s.source, s.path, fig);" in body
def test_chat_sources_and_related_rows_gate_the_figure_on_image_url() -> None: def test_chat_sources_splits_the_chips_row_from_the_images_row() -> None:
"""Both chat source surfaces (the citation chips AND the related """Phase 130 (task 01, TODO L6): ``appendSources`` stops
row — the frame's two ref tiers, built by the same server helper) interleaving figures after chips — it PARTITIONS the refs on the
render the figure ONLY when the ref carries ``image_url``: text existing image-doc signal (the ``image_url`` presence, phase 122)
refs (and every pre-phase saved ref) add zero new DOM — the and builds two clusters: the chips row from the NON-image refs
renderer stays byte-identical for them. The chip's text + ONLY (no chip for an image doc — the figure IS the citation) and
affordance stay (the figure is additive, not a replacement), and a second ``.msg-meta.msg-images`` row (``role=list`` +
the summary fetch targets the SAME content endpoint the chip's ``aria-label="Source images"``) holding one figure per image ref
modal already uses (failing soft to null — the title stands).""" 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") js = APP_JS.read_text(encoding="utf-8")
src = _function_body(js, "function appendSources(wrap, sources) {") src = _function_body(js, "function appendSources(wrap, sources) {")
assert 'if (s.image_url) appendSourceImageFigure(meta, s);' in src # The partition (the `image_url` presence is the image-doc signal):
assert 'chip.className = "source-chip";' in src, "the chip stays (additive)" 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 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) {") rel = _function_body(js, "function appendRelated(wrap, related) {")
assert 'if (s.image_url) appendSourceImageFigure(row, s);' in rel assert 'if (s.image_url) appendSourceImageFigure(row, s);' in rel
fetch = _function_body(js, "function fetchContentSummary(source, path) {") fetch = _function_body(js, "function fetchContentSummary(source, path) {")
+42 -20
View File
@@ -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 """The single guard: admin (the whoami gate Tune uses) AND
docs_repo_configured — otherwise the function injects NOTHING docs_repo_configured — otherwise the function injects NOTHING
(anonymous, or unconfigured admin, or deflected scope — same as (anonymous, or unconfigured admin, or deflected scope — same as
Tune). One button per bubble; the .msg-meta row is reused (or Tune). One button per bubble; the button joins the bubble's
created plain) and a role=list row gets a listitem button (ARIA).""" .msg-actions row (getActionsRow — phase 130 task 02; the role=list
row gets a listitem button, ARIA)."""
js = _text(APP_JS) js = _text(APP_JS)
fn_idx = js.find("function appendSaveAsDocButton(wrap, markdown) {") fn_idx = js.find("function appendSaveAsDocButton(wrap, markdown) {")
assert fn_idx != -1, "appendSaveAsDocButton missing" 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 assert "Couldn't save the answer as a doc" in fn_body
def test_app_js_retry_landing_keeps_save_rightmost() -> None: def test_app_js_retry_landing_has_no_save_reappend_hack() -> None:
"""markLastRetryable re-appends the save button AFTER the Retry """Phase 130 (task 02): markLastRetryable re-appends the Retry
button lands on the same (last) bubble — the auto-margined buttons button into the last bubble's .msg-actions row (appendRetryButton →
split the row's free space between them, so DOM order decides the getActionsRow) and NO LONGER re-appends the save button after it —
right edge: "Save as doc" stays the bottom-right action even on the the row's row-level right-align (phase 130 task 03) right-aligns
last bubble (which also carries Retry).""" 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) js = _text(APP_JS)
fn_idx = js.find("function markLastRetryable() {") fn_idx = js.find("function markLastRetryable() {")
assert fn_idx != -1 assert fn_idx != -1
fn_end = js.find("/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the bottom-right", fn_idx) 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] fn_body = js[fn_idx:fn_end]
assert "appendRetryButton(lastBrainWrap);" in fn_body assert "appendRetryButton(lastBrainWrap);" in fn_body, (
assert 'lastBrainWrap.querySelector(".save-as-doc-btn")' in fn_body "the Retry button is re-appended to the last brain bubble"
# "saveDocBtn" — NOT "saveBtn": phase 55 pins the Save pill's )
# identifier gone from app.js (substring), so the local stays distinct. assert "saveDocBtn" not in fn_body, (
assert "saveDocBtn.parentElement.appendChild(saveDocBtn)" in fn_body "the phase-59 save-button re-append hack is gone — the actions "
assert "saveBtn" not in _text(APP_JS), ( "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" "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: def test_styles_css_save_as_doc_btn_is_right_aligned() -> None:
""".save-as-doc-btn exists, carries the bottom-right declaration """.save-as-doc-btn exists in the .tune-btn visual family (pill,
(margin-inline-start: auto) and the .tune-btn visual family (pill, >=44px target, line border, ink-soft palette) with NO per-button
>=44px target, line border, ink-soft palette); :focus-visible is auto margin — phase 130 moved the bottom-right requirement to the
the global rule, the hover rule is per-class.""" 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) css = _text(STYLES_CSS)
m = re.search(r"\.save-as-doc-btn \{[^}]*\}", css) m = re.search(r"\.save-as-doc-btn \{[^}]*\}", css)
assert m, "the .save-as-doc-btn rule is missing" assert m, "the .save-as-doc-btn rule is missing"
block = m.group(0) block = m.group(0)
assert "margin-inline-start: auto;" in block, ( assert "margin-inline-start: auto" not in block and "margin-left: auto" not in block, (
"the bottom-right requirement lives on the button's class" "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 "min-height: 44px;" in block # WCAG touch target (the family)
assert "border-radius: 999px;" in block assert "border-radius: 999px;" in block
+33 -19
View File
@@ -223,26 +223,35 @@ def test_append_related_row_contract() -> None:
# ---------- task 02: wiring — the done frame and the restore path ---------- # ---------- 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)`` """The done-frame handler calls ``appendRelated(wrap, ev.related)``
— and appends the row LAST among the bubble's meta rows: AFTER — and appends the row BELOW the citation clusters and ABOVE the
``markLastRetryable()`` (the appendTuneButton / appendSaveAsDoc / actions row (phase 130 task 02): the action appenders (appendTune
appendRetryButton claimers all take the FIRST ``.msg-meta`` row, Button → markLastRetryable's Retry → appendSaveAsDocButton) FOLLOW
so a deflected turn with related docs — no citation row — gets its it in the branch, so the get-or-created ``.msg-actions`` row is the
OWN meta row for the buttons instead of actions joining the bubble's LAST row (the bottom-right cluster — DOM order is visual
related row). The call sits after the Retry claim in the branch.""" 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()) branch = _done_branch(_app_js())
assert "appendRelated(wrap, ev.related);" in branch, ( assert "appendRelated(wrap, ev.related);" in branch, (
"the done handler must render the related tier (it arrives in " "the done handler must render the related tier (it arrives in "
"ev.related — on a deflected turn ev.sources is empty and the " "ev.related — on a deflected turn ev.sources is empty and the "
"weak hits live here, row only, zero chips)" "weak hits live here, row only, zero chips)"
) )
assert branch.index("appendRelated(wrap, ev.related);") > branch.index( assert branch.index("appendRelated(wrap, ev.related);") < branch.index(
"markLastRetryable();" "appendTuneButton(wrap);"
), ( ), (
"appendRelated must run AFTER the meta-action claimers (last " "appendRelated must run BEFORE the action appenders — the "
"meta row) — a deflected turn's tune/retry buttons must land in " ".msg-actions row is the bubble's LAST row (phase 130 task 02)"
"their own row, never in the related row" )
# 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 """The phase-14 restore path (``renderStoredMessage`` — used by
BOTH the localStorage restore and the /?chat=<id> saved-chat boot BOTH the localStorage restore and the /?chat=<id> saved-chat boot
load) re-renders the related row from the stored payload when it load) re-renders the related row from the stored payload when it
carries ``related``; a pre-phase record without the field carries ``related``; a pre-phase record without the field
restores exactly as today (appendRelated no-ops — no row). The restores exactly as today (appendRelated no-ops — no row). The
call sits after the meta-row claimers, exactly like the live call sits BEFORE the action appenders (Tune / Save-as-doc / notes —
done path, so restored buttons never join the related row.""" 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) {") body = _function_body(_app_js(), "function renderStoredMessage(m) {")
assert "appendRelated(wrap, m.related);" in body, ( assert "appendRelated(wrap, m.related);" in body, (
"the restore path must render the related tier from the stored " "the restore path must render the related tier from the stored "
"payload (pre-phase records carry no `related` → no row, " "payload (pre-phase records carry no `related` → no row, "
"graceful)" "graceful)"
) )
assert body.index("appendRelated(wrap, m.related);") > body.index( assert body.index("appendRelated(wrap, m.related);") < body.index(
"appendStoppedNote(wrap);" "appendTuneButton(wrap)"
), ( ), (
"the related row appends LAST (after the claimers) on the " "the related row appends BEFORE the action appenders on the "
"restore path too — identical order to the live done path" "restore path too (the actions row is the bubble's LAST row) — "
"identical order to the live done path (phase 130 task 02)"
) )