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