diff --git a/.agents/phases/todo/132_image_upload_feedback/00_phase.md b/.agents/phases/complete/132_image_upload_feedback/00_phase.md
similarity index 100%
rename from .agents/phases/todo/132_image_upload_feedback/00_phase.md
rename to .agents/phases/complete/132_image_upload_feedback/00_phase.md
diff --git a/.agents/phases/todo/132_image_upload_feedback/01_upload_state.md b/.agents/phases/complete/132_image_upload_feedback/01_upload_state.md
similarity index 100%
rename from .agents/phases/todo/132_image_upload_feedback/01_upload_state.md
rename to .agents/phases/complete/132_image_upload_feedback/01_upload_state.md
diff --git a/.agents/phases/todo/132_image_upload_feedback/02_css_upload_state.md b/.agents/phases/complete/132_image_upload_feedback/02_css_upload_state.md
similarity index 100%
rename from .agents/phases/todo/132_image_upload_feedback/02_css_upload_state.md
rename to .agents/phases/complete/132_image_upload_feedback/02_css_upload_state.md
diff --git a/.agents/phases/todo/132_image_upload_feedback/03_e2e_upload_feedback.md b/.agents/phases/complete/132_image_upload_feedback/03_e2e_upload_feedback.md
similarity index 100%
rename from .agents/phases/todo/132_image_upload_feedback/03_e2e_upload_feedback.md
rename to .agents/phases/complete/132_image_upload_feedback/03_e2e_upload_feedback.md
diff --git a/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__00_phase.a1.err b/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__00_phase.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__00_phase.a1.md b/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__00_phase.a1.md
new file mode 100644
index 0000000..2696fc5
--- /dev/null
+++ b/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__00_phase.a1.md
@@ -0,0 +1,10 @@
+All verification complete. Final report:
+
+**Phase 132 (image upload feedback) — final verification pass: 100% green**
+
+- **Verified** all 3 tasks' implementations in the working tree: `index.html` status part (hidden-by-default, arc + "Uploading image…", pinned order), `app.js` `enterUploadState`/`exitUploadState` (entered after `attachUpload` guard pre-await, exited on BOTH resolution paths, `setUiState` untouched, idle "Send" restore byte-identical), `styles.css` `.is-uploading` rules (house `spin` keyframes, reduced-motion block, existing `:disabled` Send language), unit pins, E2E suite with route-delayed upload window.
+- **Completion criteria** — (1) mid-flight spinner + "Uploading image…", remove + Send disabled with "Uploading…": ✅ E2E-asserted; (2) second Send press = no second upload (request count exactly 1): ✅; (3) success clears + turn proceeds (bubble carries image); failure clears + banner + text/attachment remain: ✅; (4) `uv run pytest tests/e2e/test_image_upload_feedback.py -v --no-cov`: ✅ 3 passed (18.5 s, in isolation); (5) no behavior change in completed phases: ✅ (full suite green, A5 path-never-bytes re-pinned in E2E).
+- **Full suite**: `uv run pytest --cov=app --cov-report=term-missing` → **3108 passed**, `app/` coverage **99%** (>90% gate); `uv run ruff check .` → clean; `uv run pyright` → 0 errors.
+- **Documentation**: README.md fully reviewed (setup, pages, all `.env.example` vars + every default vs `app/config.py`, tools incl. phase-131 `read(offset,limit)`, image/PDF/sync/deploy sections) and AGENTS.md fully reviewed (rules, quick-reference commands, gitignore/gpg claims) — **verified consistent; no changes needed**. Phase-132 README bullet already in the working tree; CSS `:root` contrast-note corrections (15.3:1 / 8.2:1) re-verified mathematically correct (comments only).
+- **Decisions**: none — all task work was already implemented and correct; no fixes required.
+- **Next pending phase**: `133_pdf_screenshot_fallback`.
diff --git a/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__00_phase.a1.validate b/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__00_phase.a1.validate
new file mode 100644
index 0000000..5b0bb41
--- /dev/null
+++ b/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__00_phase.a1.validate
@@ -0,0 +1,115 @@
+........................................................................ [ 2%]
+........................................................................ [ 4%]
+........................................................................ [ 6%]
+........................................................................ [ 9%]
+........................................................................ [ 11%]
+........................................................................ [ 13%]
+........................................................................ [ 16%]
+........................................................................ [ 18%]
+........................................................................ [ 20%]
+........................................................................ [ 23%]
+........................................................................ [ 25%]
+........................................................................ [ 27%]
+........................................................................ [ 30%]
+........................................................................ [ 32%]
+........................................................................ [ 34%]
+........................................................................ [ 37%]
+........................................................................ [ 39%]
+........................................................................ [ 41%]
+........................................................................ [ 44%]
+........................................................................ [ 46%]
+........................................................................ [ 48%]
+........................................................................ [ 50%]
+........................................................................ [ 53%]
+........................................................................ [ 55%]
+........................................................................ [ 57%]
+........................................................................ [ 60%]
+........................................................................ [ 62%]
+........................................................................ [ 64%]
+........................................................................ [ 67%]
+........................................................................ [ 69%]
+........................................................................ [ 71%]
+........................................................................ [ 74%]
+........................................................................ [ 76%]
+........................................................................ [ 78%]
+........................................................................ [ 81%]
+........................................................................ [ 83%]
+........................................................................ [ 85%]
+........................................................................ [ 88%]
+........................................................................ [ 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 268 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 412 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 410 0 100%
+--------------------------------------------------
+TOTAL 5206 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/132_image_upload_feedback/132_image_upload_feedback__01_upload_state.a1.err b/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__01_upload_state.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__01_upload_state.a1.md b/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__01_upload_state.a1.md
new file mode 100644
index 0000000..0361ac0
--- /dev/null
+++ b/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__01_upload_state.a1.md
@@ -0,0 +1,17 @@
+All done. Final report:
+
+**Implemented (phase 132, task 01 — uploading state):**
+- `frontend/index.html`: `#attach-preview` gains the hidden status part (house loading arc svg, `aria-hidden` + pinned "Uploading image…" text) between the filename and remove ✕ — ships hidden per the house rule
+- `frontend/assets/app.js`: module-scope `attachStatus` ref (null-guard pattern); `enterUploadState()`/`exitUploadState()` helpers beside `showAttachPreview`/`clearAttachedImage` (strip `.is-uploading` + status unhidden + remove disabled + Send disabled "Uploading…" / mirror exit restoring "Send" byte-identical to `setUiState`'s idle label); `handleSend` enters after the `attachUpload` guard before the await, exits at `attachUpload = false` (both success & failure — helper never throws). `setUiState`/`uiState`/turn loader untouched (pre-turn)
+- `tests/unit/test_chat_image_questions.py`: +4 tests — markup pin (order, exact arc, hidden), helper affordance pin (order, guards, pre-turn contract, byte-identical "Send"), `handleSend` exact-ordering pin (guard → arm → enter → await → release → exit → block, single enter/exit, no `setUiState(` call, byte-untouched phase-123 contract), node-harness behavioral test (enter / exit-success / exit-failure / re-enter rows)
+
+**Gates (all green):**
+- `uv run pytest --cov=app --cov-report=term -q` → exit 0, **99%** on `app/` (>90% ✓)
+- `uv run ruff check .` → clean; `uv run pyright` → exit 0
+- Regression E2E in isolation: `test_chat_image_questions.py` (phase 123 contract on the modified surface) 5 passed; `test_smoke.py`, `test_answer_images.py`, `test_image_anchor_context.py`, `test_image_documents.py` all pass
+
+**Docs:** no doc impact (no setup/commands/env/API changes)
+
+**Decisions:** used the task's exact arc snippet (the "check `#turn-loader`" hint is stale — that loader is the brain-wave glyph, not an arc; the arc matches the house `.spinner` language for task 02's spin CSS); removed a stray `✕` glyph my comment introduced (caught by the emoji guard).
+
+**Next pending task:** `02_css_upload_state.md`
diff --git a/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__01_upload_state.a1.validate b/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__01_upload_state.a1.validate
new file mode 100644
index 0000000..38b0dc3
--- /dev/null
+++ b/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__01_upload_state.a1.validate
@@ -0,0 +1,115 @@
+........................................................................ [ 2%]
+........................................................................ [ 4%]
+........................................................................ [ 6%]
+........................................................................ [ 9%]
+........................................................................ [ 11%]
+........................................................................ [ 13%]
+........................................................................ [ 16%]
+........................................................................ [ 18%]
+........................................................................ [ 20%]
+........................................................................ [ 23%]
+........................................................................ [ 25%]
+........................................................................ [ 27%]
+........................................................................ [ 30%]
+........................................................................ [ 32%]
+........................................................................ [ 34%]
+........................................................................ [ 37%]
+........................................................................ [ 39%]
+........................................................................ [ 41%]
+........................................................................ [ 44%]
+........................................................................ [ 46%]
+........................................................................ [ 48%]
+........................................................................ [ 51%]
+........................................................................ [ 53%]
+........................................................................ [ 55%]
+........................................................................ [ 58%]
+........................................................................ [ 60%]
+........................................................................ [ 62%]
+........................................................................ [ 65%]
+........................................................................ [ 67%]
+........................................................................ [ 69%]
+........................................................................ [ 71%]
+........................................................................ [ 74%]
+........................................................................ [ 76%]
+........................................................................ [ 78%]
+........................................................................ [ 81%]
+........................................................................ [ 83%]
+........................................................................ [ 85%]
+........................................................................ [ 88%]
+........................................................................ [ 90%]
+........................................................................ [ 92%]
+........................................................................ [ 95%]
+........................................................................ [ 97%]
+........................................................................ [ 99%]
+..... [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name Stmts Miss Cover
+--------------------------------------------------
+app/__init__.py 1 0 100%
+app/api/__init__.py 0 0 100%
+app/api/auth.py 52 0 100%
+app/api/chat.py 268 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 412 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 410 0 100%
+--------------------------------------------------
+TOTAL 5206 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/132_image_upload_feedback/132_image_upload_feedback__02_css_upload_state.a1.err b/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__02_css_upload_state.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__02_css_upload_state.a1.md b/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__02_css_upload_state.a1.md
new file mode 100644
index 0000000..b8bfaff
--- /dev/null
+++ b/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__02_css_upload_state.a1.md
@@ -0,0 +1,19 @@
+Task 02 complete. Final report:
+
+**Implemented**
+- `frontend/assets/styles.css`: Phase 132 uploading-state CSS in the `.attach-preview*` family — thumbnail dims to 0.5, name hides via `visibility` (slot kept, no reflow), `.attach-preview-status` row (`--ink-soft`, 0.85rem, `min-width: 0`), 14px arc on the house `spin` keyframes at the sync spinner's 1s pace, `prefers-reduced-motion` stills the arc, remove ✕ dims 0.5 with its disabled attribute
+- Verified in real Chromium (320/375/480/768/1440): "Uploading image…" never clips (one line at 320px), strip height stable, arc animates `spin 1s` / `none` under reduced motion
+- Disabled Send: existing `.send-btn:disabled` (`--brand-busy` + `not-allowed`) already reads disabled — verified + pinned, no new visual (B4)
+- Theme audit: block ships no raw color; `--ink-soft` on `--surface` computed 8.2:1 (≥4.5:1) from `:root` literals
+- 7 new unit pins appended to `tests/unit/test_chat_image_questions.py` (Phase 132 task 02 section)
+
+**Tests / lint**
+- `uv run pytest --cov=app`: **3108 passed**, exit 0, `app/` coverage **99%** (>90% gate)
+- `uv run ruff check .`: All checks passed · `uv run pyright`: 0 errors, 0 warnings
+- Regression: `tests/e2e/test_chat_image_questions.py` (5 passed) + `test_pinned_composer.py` + `test_responsive_polish.py` (11 passed) in isolation
+
+**Docs:** no README/AGENTS.md impact (visual-state CSS only)
+
+**Decisions / deviations:** fixed stale `:root` contrast comments (5.1:1→8.2:1, 13.8:1→15.3:1 — palette changed at the phase-08 re-land, comments never updated; both pairs pass AA). ~40 other historical phase comments still cite the old low ratios (all underestimates, AA-safe) — left as out-of-scope for this task. Initial "✕" glyphs in my CSS comment tripped the phase-08 no-emoji guard — replaced with ASCII.
+
+**Next pending task:** `.agents/phases/todo/132_image_upload_feedback/03_e2e_upload_feedback.md`
diff --git a/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__02_css_upload_state.a1.validate b/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__02_css_upload_state.a1.validate
new file mode 100644
index 0000000..5b0bb41
--- /dev/null
+++ b/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__02_css_upload_state.a1.validate
@@ -0,0 +1,115 @@
+........................................................................ [ 2%]
+........................................................................ [ 4%]
+........................................................................ [ 6%]
+........................................................................ [ 9%]
+........................................................................ [ 11%]
+........................................................................ [ 13%]
+........................................................................ [ 16%]
+........................................................................ [ 18%]
+........................................................................ [ 20%]
+........................................................................ [ 23%]
+........................................................................ [ 25%]
+........................................................................ [ 27%]
+........................................................................ [ 30%]
+........................................................................ [ 32%]
+........................................................................ [ 34%]
+........................................................................ [ 37%]
+........................................................................ [ 39%]
+........................................................................ [ 41%]
+........................................................................ [ 44%]
+........................................................................ [ 46%]
+........................................................................ [ 48%]
+........................................................................ [ 50%]
+........................................................................ [ 53%]
+........................................................................ [ 55%]
+........................................................................ [ 57%]
+........................................................................ [ 60%]
+........................................................................ [ 62%]
+........................................................................ [ 64%]
+........................................................................ [ 67%]
+........................................................................ [ 69%]
+........................................................................ [ 71%]
+........................................................................ [ 74%]
+........................................................................ [ 76%]
+........................................................................ [ 78%]
+........................................................................ [ 81%]
+........................................................................ [ 83%]
+........................................................................ [ 85%]
+........................................................................ [ 88%]
+........................................................................ [ 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 268 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 412 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 410 0 100%
+--------------------------------------------------
+TOTAL 5206 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/132_image_upload_feedback/132_image_upload_feedback__03_e2e_upload_feedback.a1.err b/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__03_e2e_upload_feedback.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__03_e2e_upload_feedback.a1.md b/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__03_e2e_upload_feedback.a1.md
new file mode 100644
index 0000000..081b8b1
--- /dev/null
+++ b/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__03_e2e_upload_feedback.a1.md
@@ -0,0 +1,17 @@
+All gates green. Final report:
+
+**Implemented**
+- `tests/e2e/test_image_upload_feedback.py` (new, mock-only, run in isolation): module app with `BOR_IMAGES=true` + suite-private image store, one-doc local source seeded through the real in-process sync, house 1×1 PNG constant.
+- Three tests per the mapping: (1) mid-flight uploading state + owner-spam (2 dead button presses + Enter) → exactly ONE upload request, ONE turn; (2) success clears state, strip hides, bubble carries the data-URL image, record carries the PATH (A5), grounded turn settles; (3) delayed-`route.abort` network drop → banner copy, draft + attachment intact, no turn.
+- In-flight window via `page.route` + async `asyncio.sleep(3.0)` handler, then `route.continue_()` (real server runs, response just late); module counter pins the double-send.
+
+**Results**
+- `uv run pytest tests/e2e/test_image_upload_feedback.py -v --no-cov` → 3 passed (5 consecutive runs, 18 s each; instrumented run confirmed in-flight asserts settle ~10 ms after the click, inside the 3 s hold — not a race)
+- `uv run pytest --cov=app --cov-report=term-missing` → 3108 passed, `app/` coverage 99%
+- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors, 0 warnings
+
+**Docs:** README "Question images" gained the uploading-state bullet (phase 132 behavior); AGENTS.md — no doc impact (no new rules/commands).
+
+**Notable decisions:** async Playwright API instead of the sync house pattern — a sync route handler's sleep blocks the test thread for the whole hold, making mid-flight DOM polling impossible (verified empirically; header docstring explains). Admin form login (async twin of `auth_helpers.login`) instead of anonymous: the phase-79 token gate locks the anonymous shell, and phase 123 (the referenced suite) uses the signed-in context. Same-question auto-saves are cleaned by the suite title (house pattern).
+
+**Next pending task:** `.agents/phases/todo/133_pdf_screenshot_fallback/01_diagnosis_regression_test.md`
diff --git a/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__03_e2e_upload_feedback.a1.validate b/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__03_e2e_upload_feedback.a1.validate
new file mode 100644
index 0000000..5b0bb41
--- /dev/null
+++ b/.agents/reports/132_image_upload_feedback/132_image_upload_feedback__03_e2e_upload_feedback.a1.validate
@@ -0,0 +1,115 @@
+........................................................................ [ 2%]
+........................................................................ [ 4%]
+........................................................................ [ 6%]
+........................................................................ [ 9%]
+........................................................................ [ 11%]
+........................................................................ [ 13%]
+........................................................................ [ 16%]
+........................................................................ [ 18%]
+........................................................................ [ 20%]
+........................................................................ [ 23%]
+........................................................................ [ 25%]
+........................................................................ [ 27%]
+........................................................................ [ 30%]
+........................................................................ [ 32%]
+........................................................................ [ 34%]
+........................................................................ [ 37%]
+........................................................................ [ 39%]
+........................................................................ [ 41%]
+........................................................................ [ 44%]
+........................................................................ [ 46%]
+........................................................................ [ 48%]
+........................................................................ [ 50%]
+........................................................................ [ 53%]
+........................................................................ [ 55%]
+........................................................................ [ 57%]
+........................................................................ [ 60%]
+........................................................................ [ 62%]
+........................................................................ [ 64%]
+........................................................................ [ 67%]
+........................................................................ [ 69%]
+........................................................................ [ 71%]
+........................................................................ [ 74%]
+........................................................................ [ 76%]
+........................................................................ [ 78%]
+........................................................................ [ 81%]
+........................................................................ [ 83%]
+........................................................................ [ 85%]
+........................................................................ [ 88%]
+........................................................................ [ 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 268 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 412 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 410 0 100%
+--------------------------------------------------
+TOTAL 5206 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/README.md b/README.md
index c2cc828..ed49689 100644
--- a/README.md
+++ b/README.md
@@ -482,6 +482,13 @@ image:
to `POST /api/chat-images` (capped at `BOR_CHAT_IMAGE_MAX_MB`,
default 10 MiB — over is a 413) and stored under `BOR_CHAT_IMAGE_DIR`.
Saved and shared chats carry the returned *path* — never base64.
+- **Visible while uploading.** While the upload is in flight the
+ preview strip shows a spinner + *"Uploading image…"* (its remove ✕
+ disabled) and the Send button is disabled with the *"Uploading…"*
+ label — a second press is dead, visually and functionally (no second
+ upload, no second turn). On success the state clears and the turn
+ proceeds; on failure it clears, the banner shows, and the typed text
+ + the attachment stay for the retry (phase 132).
- **Current turn only.** The model receives a multimodal message for
this question; prior turns' images are never replayed into history
(the text of a prior turn stands alone).
diff --git a/frontend/assets/app.js b/frontend/assets/app.js
index 9f40be5..83a2133 100644
--- a/frontend/assets/app.js
+++ b/frontend/assets/app.js
@@ -314,6 +314,10 @@ const attachBtn = document.querySelector("#attach-btn");
const attachFile = document.querySelector("#attach-file");
const attachPreview = document.querySelector("#attach-preview");
const attachRemove = document.querySelector("#attach-remove");
+// Phase 132 (task 01, TODO L8): the strip's uploading status part —
+// ships hidden; enterUploadState / exitUploadState (below) are its
+// sole `hidden` writers (shown iff the image upload is in flight).
+const attachStatus = document.querySelector(".attach-preview-status");
const banner = document.querySelector("#kb-banner");
const bannerText = document.querySelector("#kb-banner-text");
const versionEl = document.querySelector("#app-version");
@@ -2757,6 +2761,49 @@ function clearAttachedImage() {
if (attachPreview) attachPreview.hidden = true;
}
+/* Phase 132 (task 01, TODO L8; A5, owner-confirmed 2026-09-27): the
+ * PRE-turn uploading state — the visible proof that the attached
+ * image is moving while POST /api/chat-images is in flight. The
+ * affordance is BOTH halves at once: the preview strip shows the
+ * spinner + "Uploading image…" (the status part unhidden, the
+ * .is-uploading class for task 02's CSS) with its remove X disabled
+ * (the attachment is mid-flight — it can't be removed out from under
+ * the upload), AND the Send button is DISABLED with the "Uploading…"
+ * label — the user-facing half of the attachUpload double-fire guard
+ * (a second press does nothing: the top-of-handleSend
+ * `sendBtn.disabled` guard AND the `attachUpload` guard both no-op).
+ * exitUploadState is the mirror image: the strip returns to its
+ * normal preview (thumbnail + filename stay visible), the remove X
+ * re-enables, and the button restores EXACTLY the idle look —
+ * enabled + "Send" (byte-identical to setUiState's idle label),
+ * restored BEFORE the success path calls runTurn (whose
+ * setUiState("thinking") then takes the button over as usual) and
+ * before the failure path returns (banner shown, input text intact,
+ * the attachment still attached — the user can retry or remove).
+ * setUiState / uiState / the turn loader are deliberately UNTOUCHED:
+ * the upload is PRE-turn (uiState stays idle; the turn's
+ * thinking/streaming feedback starts only when runTurn does).
+ * Both are idempotent and null-guarded on the attach family (the
+ * house pattern — a New chat mid-upload hides the strip; the class
+ * on a hidden element is harmless and still cleared at resolution);
+ * the send refs are unconditional (the composer is required for the
+ * chat view to exist at all). */
+function enterUploadState() {
+ if (attachPreview) attachPreview.classList.add("is-uploading");
+ if (attachStatus) attachStatus.hidden = false;
+ if (attachRemove) attachRemove.disabled = true;
+ sendBtn.disabled = true;
+ sendLabel.textContent = "Uploading…";
+}
+
+function exitUploadState() {
+ if (attachPreview) attachPreview.classList.remove("is-uploading");
+ if (attachStatus) attachStatus.hidden = true;
+ if (attachRemove) attachRemove.disabled = false;
+ sendBtn.disabled = false;
+ sendLabel.textContent = "Send";
+}
+
async function handleSend(e) {
e.preventDefault();
// Phase 48: while a turn is in flight the Send button IS the Stop
@@ -2787,8 +2834,20 @@ async function handleSend(e) {
if (attachedImage) {
if (attachUpload) return; // a second submit mid-upload: the first owns the send (never two uploads, never two turns)
attachUpload = true;
+ // Phase 132 (task 01, TODO L8): the uploading state is entered
+ // exactly ONCE per upload — after the guard, before the await —
+ // so the gap between the press and the resolution is never
+ // frozen-looking (the strip's spinner + "Uploading image…", the
+ // disabled "Uploading…" Send).
+ enterUploadState();
const path = await uploadAttachedImage(attachedImage.file);
attachUpload = false; // the helper never throws (every failure path is a banner + null)
+ // Phase 132: exited on BOTH resolution paths — the helper never
+ // throws, so this line is reached on success AND failure. Before
+ // the success path continues to runTurn (whose setUiState takes
+ // the button over) and before the failure path returns below
+ // (banner + editable composer + the attachment for the retry).
+ exitUploadState();
if (path === null) return; // A8: the send is blocked — the question stays
image = {
path, // the record + the /api/chat body (A5: the path, never base64)
diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css
index 46779e7..c1b4155 100644
--- a/frontend/assets/styles.css
+++ b/frontend/assets/styles.css
@@ -7,9 +7,9 @@
:root {
/* Palette — all text/background pairs meet WCAG 2.1 AA (>= 4.5:1) */
--bg: #0f0a0a; /* page: ink on bg 16.7:1 */
- --surface: #1a0f0f; /* ink on surface 13.8:1 */
+ --surface: #1a0f0f; /* ink on surface 15.3:1 */
--ink: #f0e6e6;
- --ink-soft: #b8a8a8; /* 5.1:1 on --surface */
+ --ink-soft: #b8a8a8; /* 8.2:1 on --surface */
--line: #2d1a1a; /* decorative 1px borders */
--grid-line: #4a2626; /* background grid texture — decorative, no
contrast duty (like --line) */
@@ -1745,6 +1745,56 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
}
.attach-preview-remove svg { width: 18px; height: 18px; }
.attach-preview-remove:hover { background: var(--err-bg); color: var(--err-ink); }
+/* Phase 132 (task 02, TODO L8): the strip's UPLOADING look — the
+ visual half of the owner-confirmed double-spam block (task 01's JS
+ state proves the image is moving while POST /api/chat-images is in
+ flight; before this the strip looked frozen and the user spammed
+ Send). The thumbnail DIMS to 0.5 (it is decorative — no contrast
+ duty — and 0.5 is the house dim value the disabled remove button
+ below); the filename HIDES with visibility (the flex: 1 slot keeps
+ its measured width — the status takes the row's right hand WITHOUT
+ reflowing the pinned 44px remove button); the house loading arc
+ (the status part's svg — task 01's markup, currentColor = the
+ status's --ink-soft) SPINS at the sync spinner's pace (the house
+ `spin` keyframes at 1s linear — .sync-icon.is-spinning's in-flight
+ pace; the .spinner's 0.8s is the busy-turn arc's). The status text
+ is --ink-soft on the strip's --surface — the :root-pinned pair,
+ 8.2:1 computed against WCAG 2.1 (AA ≥ 4.5:1), at the strip's
+ small text size (the name's 0.85rem). The remove button (task 01
+ disables it mid-flight) dims with its disabled attribute — the
+ attribute is the state, the 0.5 is its visual half; the Send rides
+ the same house :disabled language (.send-btn:disabled's
+ --brand-busy + not-allowed below — which reads as disabled already,
+ so NO new color or weight was added, B4). prefers-reduced-motion stills the arc (the house block): the
+ "Uploading image…" TEXT + the dimmed thumbnail carry the state
+ without motion (B5 — never motion alone). Every value is a theme
+ variable or a neutral number — the block ships no raw color. */
+.attach-preview.is-uploading .attach-preview-img {
+ opacity: 0.5;
+}
+.attach-preview.is-uploading .attach-preview-name {
+ visibility: hidden; /* keep the flex: 1 slot; the status takes its right hand */
+}
+.attach-preview-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ color: var(--ink-soft);
+ font-size: 0.85rem; /* the strip's small text — the name's size */
+ min-width: 0; /* the row's clamp: at 320 px the text WRAPS (no clip) */
+}
+.attach-preview-status-icon {
+ width: 14px;
+ height: 14px;
+ flex: 0 0 auto;
+ animation: spin 1s linear infinite; /* the house spin keyframes (the sync pace) */
+}
+@media (prefers-reduced-motion: reduce) {
+ .attach-preview-status-icon { animation: none; }
+}
+.attach-preview.is-uploading .attach-preview-remove {
+ opacity: 0.5; /* the disabled attribute's visual half (task 01 sets it) */
+}
.composer {
display: flex;
align-items: flex-end;
diff --git a/frontend/index.html b/frontend/index.html
index 90325ac..2811e1c 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -280,6 +280,22 @@
+
+
+
+ Uploading image…
+
diff --git a/tests/e2e/test_image_upload_feedback.py b/tests/e2e/test_image_upload_feedback.py
new file mode 100644
index 0000000..61fb5b1
--- /dev/null
+++ b/tests/e2e/test_image_upload_feedback.py
@@ -0,0 +1,602 @@
+"""Phase 132 task 03 E2E (Playwright, mock-only): the chat image upload
+shows visible "uploading" feedback (TODO L8).
+
+Run in isolation (DB must be up: ``podman compose up -d db``):
+
+ uv run pytest tests/e2e/test_image_upload_feedback.py -v --no-cov
+
+MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the settled-turn
+pins key on the mock's byte-stable grounded answer
+(``Deterministic mock answer for E2E``), which the live model will not
+produce. The in-flight mechanics themselves are LLM-independent.
+
+THE UPLOAD WINDOW (the route-delay rationale): in production
+``POST /api/chat-images`` resolves in ~50 ms — the in-flight window is
+unsamplable. ``page.route`` intercepts the upload and the handler
+DELAYS (``asyncio.sleep``) before ``route.continue_()``: the server's
+REAL handler runs, the response is just late (the upload is genuine —
+the file lands, the path is returned, the turn proceeds). The window is
+therefore ≥ ``HOLD_SECONDS`` (3.0 s) and the in-flight state — set by
+``enterUploadState`` synchronously with the fetch, cleared by
+``exitUploadState`` on resolution — is directly observable.
+
+WHY THE ASYNC API (not the house sync pattern): a sync-API route
+handler runs in a greenlet on the TEST thread — its ``time.sleep``
+blocks the thread for the whole hold, so no ``expect()`` can poll the
+DOM during the window (only the post-release residual state is visible
+— a race against the server's response time). The async API runs the
+handler as an independent event-loop task: the sleep suspends ONLY the
+handler, the test coroutine keeps polling through the driver while the
+upload is genuinely in flight. The in-flight assertions are therefore
+waits on a directly-set DOM state, not a race.
+
+Context: SIGNED IN via the house form login (admin — the same context
+the phase-123 image suite drives; the async twin of
+``e2e.auth_helpers.login``, no cookie surgery). The paperclip's reveal
+gate is the ``/api/config`` ``images: true`` flag, not identity — but
+the shell's anonymous boot shows the phase-79 token gate (locked +
+inert), so a signed-in context is the house chat context. The
+suite's module app boots with ``BOR_IMAGES=true`` pinned EXPLICITLY
+(process env ranks above an operator's local gitignored ``.env`` — the
+leak-guard convention) and a suite-private ``BOR_CHAT_IMAGE_DIR``.
+
+KB fixture: ONE local source (``tmp_path_factory`` dir, registered
+through the authenticated API + the real in-process ``POST /api/sync``
+— the ``test_chat_answer_clusters.py`` pattern) holding ONE text doc
+whose body shares the question's tokens (token-overlap cosine ~0.46–0.67
+vs the mock-calibrated 0.30 gate): the turn after a successful upload
+completes GROUNDED, exactly as pre-phase. The image fixture is the
+house 1×1 transparent PNG (inline constant — the pipeline is
+content-agnostic; the well-formed bytes keep the upload + render pins
+honest).
+
+Test → observable mapping (Playwright Mapping Rule):
+1. ``test_upload_window_shows_uploading_and_blocks_resend`` — attach →
+ type → send, the upload route-delayed: MID-FLIGHT (asserted by
+ waiting on the class while the ≥3 s window is open) the strip has
+ ``is-uploading`` + the visible ``.attach-preview-status`` reading
+ "Uploading image…" (the thumbnail stays visible — the user watches
+ THEIR image moving), ``#attach-remove`` is disabled, ``#send-btn``
+ is disabled with the "Uploading…" label; the owner's exact spam —
+ two more presses on the disabled button (the browser suppresses the
+ DOM event on a disabled control: genuinely dead presses) plus an
+ Enter-to-submit (the form's ``sendBtn.disabled`` guard) — then, past
+ the window: EXACTLY ONE upload request reached the interceptor
+ (no double upload) and EXACTLY ONE user + ONE brain bubble (no
+ second turn), the state cleared, the turn settled.
+2. ``test_upload_success_clears_and_turn_proceeds`` — the same flow,
+ past the window: the state is cleared (no ``is-uploading``, the
+ status hidden, the remove re-enabled, the label back to "Send"), the
+ strip HIDEs with the send (the phase-123 contract), the user bubble
+ carries the attached image (the live data URL, alt = the filename),
+ the brain bubble is the mock's GROUNDED answer (not a deflection),
+ and the localStorage record carries the stored PATH — never base64
+ (A5).
+3. ``test_upload_failure_clears_state_and_keeps_the_draft`` — a route
+ that DELAYS then ABORTS the upload (the network-drop path —
+ ``fetch`` rejects, ``uploadAttachedImage``'s catch): mid-flight the
+ same uploading state; after resolution the state is CLEARED (Send
+ re-enabled with "Send"), the OUT-OF-TURN banner shows the
+ network-drop copy ("Couldn't attach the image — try again."), the
+ input text is INTACT, the strip is back in its NORMAL preview
+ (the attachment remains for the retry — visible, no
+ ``is-uploading``), and NO turn ran (no bubbles at all).
+"""
+from __future__ import annotations
+
+import asyncio
+import base64
+import json
+import os
+import re
+import subprocess
+import sys
+import time
+from collections.abc import Iterator
+from contextlib import contextmanager
+from pathlib import Path
+from typing import Any
+from urllib.parse import urlsplit
+
+import httpx
+import pytest
+from playwright.async_api import Page, async_playwright, expect
+from sqlalchemy import text
+
+from app.config import Settings
+from app.db import SessionLocal
+from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
+
+REPO = Path(__file__).resolve().parents[2]
+
+# Per-module app port (the conftest session app owns its port in a
+# combined run — the module app binds its own; env-overridable).
+APP_PORT = int(os.environ.get("E2E_APP_PORT_UPLOADFB", "8180"))
+APP_URL = f"http://127.0.0.1:{APP_PORT}"
+
+#: The upload hold (the task's ≥2 s window; 3.0 s keeps the in-flight
+#: assertions + the spam comfortably inside the window even under slow
+#: driver polling — the hold is the ONLY thing between the press and
+#: the resolution, so the window is hold + intercept latency).
+HOLD_SECONDS = 3.0
+SETTLE_TIMEOUT_MS = 30_000
+
+# --------------------------------------------------------------------------
+# The fixtures' constants
+# --------------------------------------------------------------------------
+
+#: The house 1×1 transparent PNG (the inline constant of the image E2E
+#: suites — no new binary committed).
+PNG_1X1 = base64.b64decode(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
+ "AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
+)
+PNG_NAME = "diagram.png"
+
+SOURCE_NAME = "e2e-uploadfb" # the directory basename = the source name
+NOTE_PATH = "note.md"
+
+#: The one text doc — its body carries the question's tokens, so the
+#: mock's token-overlap cosine against the (single) chunk clears the
+#: mock-calibrated 0.30 gate deterministically (~0.46–0.67 measured):
+#: the post-upload turn GROUNDS (HIGH), the mock's default grounded
+#: answer streams, and the turn completes exactly as pre-phase.
+NOTE_CONTENT = (
+ "# Homelab Router Note\n"
+ "\n"
+ "The homelab router note for the e2e upload-feedback suite: the core\n"
+ "router runs the firewall rules and the VLAN interfaces, and the\n"
+ "uplink carries the whole homelab network.\n"
+)
+
+QUESTION = "What does my homelab note say about the core router?"
+#: The phase-50 auto-title (first question, whitespace-collapsed, 120-
+#: char cap) of the two settled turns — the shared e2e Postgres keeps
+#: ``saved_chats`` across runs, so the module boundary owns this
+#: suite's rows by title (the test_chat_answer_clusters cleanup).
+SUITE_TITLE = " ".join(QUESTION.split())[:120]
+MOCK_MARKER = "Deterministic mock answer for E2E"
+DEFLECT_PHRASE = r"haven't done anything like that"
+IMAGE_PATH_RE = re.compile(r"^/api/chat-images/[0-9a-f]{32}\.png$")
+STORAGE_KEY = "bor.chat.v1"
+
+#: The module counter the task's double-send assertion keys on — every
+#: intercepted upload request (method + path checked in the handler).
+UPLOAD_REQUESTS: list[str] = []
+
+
+# ---------------------------------------------------------------------------
+# Module app: images ON, suite-private image store, the one-doc local
+# source as the sole registered source
+# ---------------------------------------------------------------------------
+
+
+def _app_env(mock_llm: int, scratch: Path) -> dict[str, str]:
+ """The conftest app env (leak guards included) with the phase-123
+ image knob pinned EXPLICITLY (the ``/api/config`` ``images: true``
+ gate reveals ``#attach-btn``) and the question-image store in the
+ suite's scratch dir (the app under test never writes into the
+ owner's real ``~/bor-sources``)."""
+ 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 one-doc KB grounds
+ # the question on these values (see NOTE_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): docs-push inert; UI strings at the
+ # code defaults.
+ env["BOR_DOCS_REPO"] = ""
+ 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
+ # The story's knob + the suite-private stores.
+ env["BOR_IMAGES"] = "true"
+ env["BOR_ANSWER_IMAGES"] = "false" # the mock never show_images anyway
+ env["BOR_CHAT_IMAGE_DIR"] = str(scratch / "chat-images")
+ env["BOR_UPLOAD_DIR"] = str(scratch / "uploads")
+ env["BOR_SOURCES_DIR"] = str(scratch / "checkouts")
+ env["BOR_IMAGE_DIR"] = str(scratch / "images")
+ return env
+
+
+@pytest.fixture(scope="module")
+def kb_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
+ """The one-source fixture tree (see the module docstring): the app
+ runs on the same host, so the path is visible to it."""
+ scratch = tmp_path_factory.mktemp("bor_uploadfb")
+ src = scratch / SOURCE_NAME
+ src.mkdir()
+ (src / NOTE_PATH).write_text(NOTE_CONTENT, encoding="utf-8")
+ return src
+
+
+@pytest.fixture(scope="module")
+def app_server(mock_llm: int, kb_dir: Path) -> Iterator[str]:
+ """The real app under test — per-module app (the conftest pattern;
+ the session app is never started in this isolated run, so no port
+ clash)."""
+ scratch = kb_dir.parent
+ 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=_app_env(mock_llm, scratch),
+ )
+ 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 (the house patterns)
+# ---------------------------------------------------------------------------
+
+
+def _truncate_all() -> None:
+ """Fresh registry + KB (the E2E isolation pattern): the suites share
+ one Postgres, and a leftover row would corrupt the one-doc premise.
+ ``saved_chats`` is never truncated (the house pattern) — the two
+ settled turns auto-save rows under this suite's title, and
+ :func:`_cleanup_saved_rows` owns them."""
+ 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 _cleanup_saved_rows() -> None:
+ """Delete THIS suite's auto-saved rows by title (best-effort: a
+ failed delete — DB unreachable, the skip case — never masks the
+ skip; re-runnable on the shared DB)."""
+ try:
+ with SessionLocal() as db:
+ db.execute(
+ text("DELETE FROM saved_chats WHERE title = :title"),
+ {"title": SUITE_TITLE},
+ )
+ db.commit()
+ except Exception: # noqa: BLE001 — DB unreachable: the skip handles it
+ pass
+
+
+@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_chat_answer_clusters)."""
+ 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."""
+ 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}")
+
+
+@pytest.fixture(autouse=True)
+def seeded_kb(app_url: str, kb_dir: Path, db_ready: None) -> Iterator[None]:
+ """The story's precondition per test: a FRESH registry holding ONLY
+ this suite's local source, synced by the REAL in-process
+ ``POST /api/sync`` pipeline (walk → chunk → embed → overview →
+ version bump) under the deterministic mock — exactly ONE document.
+ (Re-seeded per test — the shared e2e Postgres makes leftovers
+ possible, and the one-doc premise is load-bearing for the gate.)"""
+ _cleanup_saved_rows() # a previous run's rows would be stale
+ _truncate_all()
+ with _admin_client(app_url) as client:
+ r = client.post("/api/git-sources", json={"kind": "local", "path": str(kb_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
+ assert body["detail"]["added"] == 1, body
+ yield
+ _truncate_all()
+ _cleanup_saved_rows()
+
+
+# ---------------------------------------------------------------------------
+# Browser helpers (async API — see the header's WHY THE ASYNC API)
+# ---------------------------------------------------------------------------
+
+
+def _png_file(tmp_path: Path) -> Path:
+ png = tmp_path / PNG_NAME
+ png.write_bytes(PNG_1X1)
+ return png
+
+
+async def _admin_login(page: Page, app_url: str) -> None:
+ """The house form login (the async twin of ``e2e.auth_helpers.
+ login``): the REAL form on /login.html — fill → submit → redirect.
+ The phase-79 gate locks the anonymous shell (inert + the token
+gate is the only interactive surface), so the suite drives the same
+ signed-in context the other chat suites do."""
+ await page.goto(f"{app_url}/login.html?next=/")
+ await expect(page.locator("#login-password")).to_be_visible()
+ await page.fill("#login-password", ADMIN_PASSWORD)
+ await page.click("#login-form button[type=submit]")
+ await expect(page).to_have_url(app_url + "/", timeout=SETTLE_TIMEOUT_MS)
+
+
+def _upload_route_handler(fail: bool) -> Any:
+ """The route-delayed upload (or the delayed ABORT — the failure
+ test's network drop). Only the POST upload is held; anything else
+ (the bytes route's GETs) passes straight through. The request is
+ counted the moment it is intercepted — the double-send pin."""
+
+ async def handle(route: Any) -> None:
+ if (
+ route.request.method == "POST"
+ and urlsplit(route.request.url).path == "/api/chat-images"
+ ):
+ UPLOAD_REQUESTS.append(route.request.url)
+ await asyncio.sleep(HOLD_SECONDS)
+ if fail:
+ await route.abort("failed")
+ return
+ await route.continue_()
+
+ return handle
+
+
+async def _attach_via_button(page: Page, png: Path) -> None:
+ """The real user path: click the paperclip (revealed by the
+ ``images: true`` config gate) → the file chooser (the button's
+ programmatic input click) → ``set_files``. The preview strip must
+ reveal with the data-URL thumbnail + the filename (phase 123)."""
+ btn = page.locator("#attach-btn")
+ await expect(btn).to_be_visible()
+ await expect(btn).to_have_attribute("aria-label", "Attach an image")
+ async with page.expect_file_chooser() as fc_info:
+ await btn.click()
+ await (await fc_info.value).set_files(str(png))
+ await expect(page.locator("#attach-preview")).to_be_visible()
+ await expect(page.locator("#attach-preview .attach-preview-name")).to_have_text(PNG_NAME)
+ src = await page.locator("#attach-preview img").get_attribute("src")
+ assert (src or "").startswith("data:image/png;base64,"), "the thumbnail is the live data URL"
+
+
+async def _assert_uploading(page: Page) -> None:
+ """The in-flight state (phase 132 task 01) — every assertion is a
+ WAIT on the directly-set DOM state (the UI is no-poll: the state is
+ set synchronously with the fetch), so while the window is open each
+ wait settles on the first tick inside it."""
+ await expect(page.locator("#attach-preview")).to_have_class(
+ re.compile(r"\bis-uploading\b")
+ )
+ # The thumbnail stays visible — the user watches THEIR image moving.
+ await expect(page.locator("#attach-preview img")).to_be_visible()
+ await expect(page.locator(".attach-preview-status")).to_be_visible()
+ await expect(page.locator(".attach-preview-status-text")).to_have_text("Uploading image…")
+ await expect(page.locator("#attach-remove")).to_be_disabled()
+ await expect(page.locator("#send-btn")).to_be_disabled()
+ await expect(page.locator("#send-label")).to_have_text("Uploading…")
+
+
+async def _send_with_held_upload(
+ page: Page, app_url: str, png: Path, *, fail: bool = False, spam: bool = False
+) -> None:
+ """Chat view (house form login) → attach → type → (hold the upload
+ route) → send → assert the in-flight state → optionally the owner's
+ exact spam (mid-flight: two presses on the disabled button + an
+ Enter-to-submit)."""
+ UPLOAD_REQUESTS.clear()
+ await _admin_login(page, app_url)
+ await _attach_via_button(page, png)
+ await page.fill("#message-input", QUESTION)
+ await page.route("**/api/chat-images", _upload_route_handler(fail))
+ await page.click("#send-btn")
+ await _assert_uploading(page)
+ if spam:
+ # The owner's exact spam while the window is open: two more
+ # presses on the disabled button (force=True dispatches the
+ # mouse events; the BROWSER suppresses the DOM event on a
+ # disabled control — genuinely dead presses, like a user's)
+ # and an Enter-to-submit (the form's sendBtn.disabled guard).
+ await page.click("#send-btn", force=True)
+ await page.click("#send-btn", force=True)
+ await page.locator("#message-input").press("Enter")
+
+
+async def _wait_grounded_settle(page: Page) -> None:
+ """Past the window: the upload resolved, the state CLEARED, and the
+ turn settled GROUNDED on the one-doc KB (the mock's byte-stable
+ answer — not a deflection), the button back to the idle "Send"."""
+ brain = page.locator(".msg.brain .bubble").first
+ await expect(brain).to_contain_text(MOCK_MARKER, timeout=SETTLE_TIMEOUT_MS)
+ await expect(brain).not_to_contain_text(re.compile(DEFLECT_PHRASE, re.IGNORECASE))
+ await expect(page.locator("#send-btn")).to_be_enabled()
+ await expect(page.locator("#send-label")).to_have_text("Send")
+ # The uploading state is gone at every writer's exit point…
+ strip = page.locator("#attach-preview")
+ assert "is-uploading" not in (await strip.get_attribute("class") or "")
+ await expect(page.locator(".attach-preview-status")).to_be_hidden()
+ await expect(page.locator("#attach-remove")).to_be_enabled()
+ # …and the strip must not linger into the turn (the phase-123
+ # contract — the preview clears when the question goes out).
+ await expect(strip).to_be_hidden()
+ # A successful turn shows no out-of-turn banner.
+ await expect(page.locator("#kb-banner")).to_be_hidden()
+
+
+# ---------------------------------------------------------------------------
+# 1. The window: uploading state visible, the spam is visibly AND
+# functionally dead (exactly ONE upload, ONE turn)
+# ---------------------------------------------------------------------------
+
+
+async def _scenario_window_blocks_resend(app_url: str, tmp_path: Path) -> None:
+ png = _png_file(tmp_path)
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ try:
+ page = await browser.new_page(viewport={"width": 1280, "height": 800})
+ page.set_default_timeout(30_000)
+ await _send_with_held_upload(page, app_url, png, spam=True)
+ await _wait_grounded_settle(page)
+ # The spam is FUNCTIONALLY dead too: exactly ONE upload
+ # request reached the interceptor (the second press never
+ # fired an upload; the Enter never fired a send).
+ assert len(UPLOAD_REQUESTS) == 1, UPLOAD_REQUESTS
+ # …and no second turn: exactly one question + one answer.
+ await expect(page.locator(".msg.user .bubble")).to_have_count(1)
+ await expect(page.locator(".msg.brain .bubble")).to_have_count(1)
+ finally:
+ await browser.close()
+
+
+def test_upload_window_shows_uploading_and_blocks_resend(
+ app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
+) -> None:
+ asyncio.run(_scenario_window_blocks_resend(app_url, tmp_path))
+
+
+# ---------------------------------------------------------------------------
+# 2. Success: the indicator clears, the strip hides, the bubble carries
+# the image, the record carries the PATH, the turn completes
+# ---------------------------------------------------------------------------
+
+
+async def _scenario_success_clears_and_proceeds(app_url: str, tmp_path: Path) -> None:
+ png = _png_file(tmp_path)
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ try:
+ page = await browser.new_page(viewport={"width": 1280, "height": 800})
+ page.set_default_timeout(30_000)
+ await _send_with_held_upload(page, app_url, png)
+ await _wait_grounded_settle(page)
+ # The user bubble renders the attached image (the phase-123
+ # attachBubbleImage — the live data URL, alt = the filename).
+ img = page.locator(".msg.user .msg-image").first
+ await expect(img).to_be_visible()
+ src = await img.get_attribute("src") or ""
+ assert src.startswith("data:image/png;base64,"), "the live bubble uses the data URL"
+ assert base64.b64decode(src.split(",", 1)[1]) == PNG_1X1
+ assert await img.get_attribute("alt") == PNG_NAME
+ # A5 at the storage boundary: the record carries the stored
+ # PATH, and NOTHING base64 crossed into the payload.
+ raw = await page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
+ stored = json.loads(raw) if raw else None
+ assert stored is not None, "the conversation must be persisted (save point 1)"
+ path = stored["messages"][0]["image"]
+ assert IMAGE_PATH_RE.fullmatch(path), f"the record must carry the stored PATH: {path!r}"
+ assert "base64" not in json.dumps(stored)
+ assert len(UPLOAD_REQUESTS) == 1, UPLOAD_REQUESTS
+ finally:
+ await browser.close()
+
+
+def test_upload_success_clears_and_turn_proceeds(
+ app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
+) -> None:
+ asyncio.run(_scenario_success_clears_and_proceeds(app_url, tmp_path))
+
+
+# ---------------------------------------------------------------------------
+# 3. Failure (the network drop): the state clears, the banner shows,
+# the draft + the attachment remain for the retry, NO turn ran
+# ---------------------------------------------------------------------------
+
+
+async def _scenario_failure_clears_and_keeps_draft(app_url: str, tmp_path: Path) -> None:
+ png = _png_file(tmp_path)
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ try:
+ page = await browser.new_page(viewport={"width": 1280, "height": 800})
+ page.set_default_timeout(30_000)
+ # The route DELAYS (the in-flight state is assertable, same
+ # as the other tests) then ABORTS — the fetch rejects and
+ # uploadAttachedImage's catch settles the network-drop
+ # banner.
+ await _send_with_held_upload(page, app_url, png, fail=True)
+ # The OUT-OF-TURN banner (the network-drop copy).
+ banner = page.locator("#kb-banner")
+ await expect(banner).to_be_visible(timeout=SETTLE_TIMEOUT_MS)
+ await expect(banner).to_have_class(re.compile(r"\bis-error\b"))
+ await expect(page.locator("#kb-banner-text")).to_contain_text(
+ "Couldn't attach the image — try again."
+ )
+ # The uploading state is CLEARED on the failure path too.
+ strip = page.locator("#attach-preview")
+ assert "is-uploading" not in (await strip.get_attribute("class") or "")
+ await expect(page.locator(".attach-preview-status")).to_be_hidden()
+ await expect(page.locator("#attach-remove")).to_be_enabled()
+ await expect(page.locator("#send-btn")).to_be_enabled()
+ await expect(page.locator("#send-label")).to_have_text("Send")
+ # The strip is back in its NORMAL preview — the attachment
+ # remains for the retry (the thumbnail + the filename).
+ await expect(strip).to_be_visible()
+ await expect(
+ page.locator("#attach-preview .attach-preview-name")
+ ).to_have_text(PNG_NAME)
+ src = await page.locator("#attach-preview img").get_attribute("src") or ""
+ assert src.startswith("data:image/png;base64,"), "the preview keeps the data URL"
+ # The typed question is INTACT (the send was blocked — A8).
+ assert await page.locator("#message-input").input_value() == QUESTION
+ # NO turn ran (the question never went out without the image).
+ await expect(page.locator(".msg.user")).to_have_count(0)
+ await expect(page.locator(".msg.brain")).to_have_count(0)
+ # The request was intercepted exactly once (it never reached
+ # the server — the abort dropped it).
+ assert len(UPLOAD_REQUESTS) == 1, UPLOAD_REQUESTS
+ finally:
+ await browser.close()
+
+
+def test_upload_failure_clears_state_and_keeps_the_draft(
+ app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
+) -> None:
+ asyncio.run(_scenario_failure_clears_and_keeps_draft(app_url, tmp_path))
diff --git a/tests/unit/test_chat_image_questions.py b/tests/unit/test_chat_image_questions.py
index 933ce95..6fdabb9 100644
--- a/tests/unit/test_chat_image_questions.py
+++ b/tests/unit/test_chat_image_questions.py
@@ -34,6 +34,19 @@ wiring (a recording fake LLM, a fake retriever, a fake DB session,
patched settings) so the whole validate → build contract runs without
a stack; the upload/serve tests point ``chat_image_dir`` at a tmp
directory (the raw-string ``expanduser`` convention).
+
+Phase 132 (task 01, TODO L8) pins the PRE-turn uploading state in the
+same chat-frontend surface: the preview strip ships a HIDDEN status
+part (the house loading arc — decorative, aria-hidden — + the pinned
+"Uploading image…" text), and ``handleSend``'s attachedImage branch
+enters it exactly once per upload (after the ``attachUpload`` guard,
+before the ``await uploadAttachedImage``) and exits it on BOTH
+resolution paths (the helper never throws) — strip class
+``is-uploading`` + status unhidden + remove ✕ disabled + the Send
+button disabled with "Uploading…" on entry; the mirror image on exit
+with the idle label restored byte-for-byte. ``setUiState`` / ``uiState``
+/ the turn loader stay untouched (the upload is pre-turn; ``uiState``
+stays ``idle``).
"""
from __future__ import annotations
@@ -41,6 +54,8 @@ import base64
import io
import json
import re
+import shutil
+import subprocess
import uuid
from collections.abc import Iterator
from datetime import UTC, datetime
@@ -1168,3 +1183,425 @@ def test_the_bad_pick_and_the_reader_failure_keep_the_banner_copy() -> None:
m = re.search(r'reader\.onerror = \(\) => \{(.*?)\n \};', js, re.S)
assert m, "the FileReader's onerror branch must exist"
assert 'showErrorBanner("Couldn\'t attach the image — try again.");' in m.group(1)
+
+
+# =====================================================================
+# Phase 132 (task 01, TODO L8) — the PRE-turn uploading state
+# =====================================================================
+
+
+def _fn(js: str, name: str) -> str:
+ """The source of the first `function ` in js (brace balanced
+ — the house pin pattern from tests/unit/test_sync_button.py)."""
+ fn = js.find(f"function {name}")
+ assert fn != -1, f"{name} must be defined"
+ open_idx = js.find("{", fn)
+ depth = 0
+ for i in range(open_idx, len(js)):
+ c = js[i]
+ if c == "{":
+ depth += 1
+ elif c == "}":
+ depth -= 1
+ if depth == 0:
+ return js[fn : i + 1]
+ raise AssertionError(f"unbalanced braces in {name}")
+
+
+def test_the_strip_ships_the_hidden_uploading_status_part() -> None:
+ """The preview strip's markup gains ONE status part INSIDE the
+ strip — after the filename span, before the remove button —
+ shipping HIDDEN (the house "ships hidden" rule): the house
+ loading arc (the svg is decorative, aria-hidden — the arc is not
+ the meaning) + the pinned "Uploading image…" text (it carries the
+ meaning). The strip's existing parts (thumbnail, name, remove ✕
+ + its aria-label) are untouched, and the thumbnail + filename stay
+ visible throughout the upload (the user watches THEIR image
+ uploading — only the status part's `hidden` moves)."""
+ html = _index_html()
+ start = html.find('
", start)]
+ # the status part: the exact shipped markup (hidden by default).
+ assert '' in strip
+ assert (
+ '' in strip
+ ), "the house loading arc (decorative, aria-hidden) must ship"
+ assert 'Uploading image…' in strip
+ # exactly ONE status part, in the pinned order: img → name →
+ # status → remove (inside the strip, before its closing tag).
+ assert strip.count('class="attach-preview-status"') == 1
+ i_img = strip.find('class="attach-preview-img"')
+ i_name = strip.find('class="attach-preview-name"')
+ i_status = strip.find('class="attach-preview-status" hidden')
+ i_remove = strip.find('id="attach-remove"')
+ assert -1 < i_img < i_name < i_status < i_remove, (
+ "the status part sits after the name, before the remove button"
+ )
+ # the pre-phase parts are byte-untouched (the phase-123 contract).
+ assert '' in strip
+ assert 'aria-label="Remove the attached image"' in strip
+
+
+def test_enter_exit_upload_state_are_the_exact_affordance_writers() -> None:
+ """The two tiny helpers next to showAttachPreview / clearAttachedImage
+ (A5, owner-confirmed 2026-09-27 — the double-spam block is the
+ BOTH-halves affordance). enterUploadState: the strip gains the
+ .is-uploading class (task 02's CSS hooks it), the status part
+ unhides, the remove ✕ disables (the attachment is mid-flight), and
+ the Send button disables with the "Uploading…" label. exitUploadState
+ is the mirror image — and its idle restore is BYTE-IDENTICAL to
+ setUiState's idle label ("Send"), so the success path hands the
+ button to runTurn's setUiState("thinking") as usual and the
+ failure path leaves a fully editable composer. The attach family
+ is null-guarded (the house pattern); the send refs are
+ unconditional (the composer is required). setUiState / uiState /
+ the turn loader are NEVER touched — the upload is PRE-turn.
+ The module-scope ref is the strip's status part (the text is
+ static — no attachStatusText)."""
+ js = _app_js()
+ enter = _fn(js, "enterUploadState")
+ exit_ = _fn(js, "exitUploadState")
+ # entry — the full affordance, in the pinned order.
+ i_preview = enter.find('attachPreview.classList.add("is-uploading")')
+ i_status = enter.find("attachStatus.hidden = false")
+ i_remove = enter.find("attachRemove.disabled = true")
+ i_send = enter.find("sendBtn.disabled = true")
+ i_label = enter.find('sendLabel.textContent = "Uploading…"')
+ assert -1 < i_preview < i_status < i_remove < i_send < i_label
+ # exit — the mirror image, ending on the exact idle label.
+ i_preview = exit_.find('attachPreview.classList.remove("is-uploading")')
+ i_status = exit_.find("attachStatus.hidden = true")
+ i_remove = exit_.find("attachRemove.disabled = false")
+ i_send = exit_.find("sendBtn.disabled = false")
+ i_label = exit_.find('sendLabel.textContent = "Send"')
+ assert -1 < i_preview < i_status < i_remove < i_send < i_label
+ # the idle restore is byte-identical to setUiState's idle label —
+ # the same literal "Send", so no stale "Uploading…" can survive.
+ assert 'sendLabel.textContent = inFlight ? "Stop" : "Send";' in js
+ # the house null-guard pattern on the attach family only (a New
+ # chat mid-upload hides the strip; the class on a hidden element
+ # is harmless and still cleared at resolution); the send refs are
+ # unconditional (the chat view cannot exist without the composer).
+ for fn_src in (enter, exit_):
+ for guarded in ("attachPreview", "attachStatus", "attachRemove"):
+ assert f"if ({guarded})" in fn_src, f"{guarded} must be null-guarded"
+ assert "if (sendBtn)" not in fn_src and "if (sendLabel)" not in fn_src
+ # the PRE-turn contract: neither helper may touch the turn's
+ # state owners (the turn loader spins only when the turn starts).
+ for fn_src in (enter, exit_):
+ assert "setUiState" not in fn_src
+ assert "uiState" not in fn_src
+ assert "turnLoader" not in fn_src
+ assert "innerHTML" not in fn_src, "the house rule: textContent only"
+ # the module-scope ref: the status part (no text ref — static).
+ assert 'const attachStatus = document.querySelector(".attach-preview-status");' in js
+ assert "attachStatusText" not in js
+
+
+def test_handle_send_wraps_the_await_with_the_upload_state() -> None:
+ """In ``handleSend``'s attachedImage branch: ``enterUploadState()``
+ lands EXACTLY ONCE — after the ``attachUpload = true;`` guard line
+ and BEFORE ``await uploadAttachedImage`` (the moment the upload
+ starts — never before, never twice); ``exitUploadState()`` lands
+ EXACTLY ONCE — after the ``attachUpload = false;`` line (the
+ helper never throws, so this is reached on BOTH success and
+ failure) and BEFORE the ``if (path === null) return;`` block
+ (the failure path exits the state, then returns with the banner +
+ the editable composer + the attachment for the retry). The
+ ``attachUpload`` guard is byte-untouched, the upload helper is
+ still called from exactly one site (the def + the call), and
+ ``handleSend`` itself never calls ``setUiState`` (the pre-turn
+ contract — the turn's own state takes over inside runTurn)."""
+ js = _app_js()
+ # the EXACT handleSend body (brace matched — the file-order
+ # neighbors of handleSend carry their own setUiState references in
+ # their docstrings; the pin is on this function alone).
+ body = _fn(js, "handleSend")
+ i_guard = body.index("if (attachUpload) return;")
+ i_arm = body.index("attachUpload = true;")
+ i_enter = body.index("enterUploadState();")
+ i_await = body.index("await uploadAttachedImage(attachedImage.file)")
+ i_release = body.index("attachUpload = false;")
+ i_exit = body.index("exitUploadState();")
+ i_block = body.index("if (path === null) return;")
+ assert -1 < i_guard < i_arm < i_enter < i_await < i_release < i_exit < i_block, (
+ "guard → arm → ENTER → await → release → EXIT → block, in order"
+ )
+ assert body.count("enterUploadState();") == 1, "entered exactly once per upload"
+ assert body.count("exitUploadState();") == 1, (
+ "exited exactly once — the helper never throws, one line covers both paths"
+ )
+ # the pre-phase contract is byte-untouched: the guard, the single
+ # call site of the upload helper (def + call = 2 occurrences),
+ # and the A8 block line.
+ assert (
+ "if (attachUpload) return; // a second submit mid-upload: "
+ "the first owns the send (never two uploads, never two turns)" in js
+ )
+ assert js.count("uploadAttachedImage") == 2
+ # handleSend itself never drives the turn's UI state — the upload
+ # is PRE-turn (uiState stays idle across the whole flight); the
+ # call is absent (the word survives only in the phase-48 comment).
+ assert "setUiState(" not in body
+
+
+#: The node harness (the test-document-viewer pattern): the REAL
+#: extracted enterUploadState / exitUploadState run against stub
+#: element refs; every transition is snapshotted as a JSON row —
+#: [tag, isUploading, statusHidden, removeDisabled, sendDisabled,
+#: label]. Plain strings only (the script is concatenation-assembled).
+_UPLOAD_NODE_HEAD = """
+const _classes = new Set();
+const attachPreview = {
+ classList: {
+ add: (c) => _classes.add(c),
+ remove: (c) => _classes.delete(c),
+ contains: (c) => _classes.has(c),
+ },
+};
+const attachStatus = { hidden: true };
+const attachRemove = { disabled: false };
+const sendBtn = { disabled: false };
+const sendLabel = { textContent: "Send" };
+const show = (tag) => {
+ console.log(JSON.stringify([
+ tag,
+ _classes.has("is-uploading"),
+ attachStatus.hidden,
+ attachRemove.disabled,
+ sendBtn.disabled,
+ sendLabel.textContent,
+ ]));
+};
+"""
+
+_UPLOAD_NODE_TAIL = """
+show("initial");
+enterUploadState();
+show("enter");
+exitUploadState();
+show("exit-success");
+enterUploadState();
+show("re-enter");
+exitUploadState();
+show("exit-failure");
+"""
+
+#: node availability (the test-document-viewer pattern).
+HAVE_NODE = shutil.which("node") is not None
+
+
+def _run_node(script: str) -> str:
+ proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60)
+ assert proc.returncode == 0, f"node failed: {proc.stderr}"
+ return proc.stdout
+
+
+@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
+def test_upload_state_transitions_under_node() -> None:
+ """Behavioral check (node) of the REAL enterUploadState /
+ exitUploadState against stub element refs (the test-document-
+ viewer pattern), across the pinned transition set:
+
+ * entry: the strip gains .is-uploading, the status part unhides,
+ the remove ✕ disables, the Send disables with "Uploading…";
+ * exit (success path): the mirror image — class removed, status
+ re-hidden, remove re-enabled, Send re-enabled with "Send";
+ * exit (failure path — the same call, banner the failure's
+ signal, not the strip's): byte-identical to the success exit —
+ the state is fully cleared either way (the user can retry or
+ remove).
+ """
+ js = _app_js()
+ fns = "\n".join(_fn(js, name) for name in ("enterUploadState", "exitUploadState"))
+ rows = [
+ json.loads(line)
+ for line in _run_node(_UPLOAD_NODE_HEAD + fns + _UPLOAD_NODE_TAIL).splitlines()
+ if line
+ ]
+ by_tag = {row[0]: row for row in rows}
+ # row = [tag, isUploading, statusHidden, removeDisabled, sendDisabled, label]
+ assert by_tag["initial"] == ["initial", False, True, False, False, "Send"]
+ assert by_tag["enter"] == ["enter", True, False, True, True, "Uploading…"], (
+ "entry is the full affordance — strip + both halves of the button"
+ )
+ # success and failure exits are byte-identical: fully cleared, the
+ # idle label restored exactly.
+ for tag in ("exit-success", "exit-failure"):
+ assert by_tag[tag] == [tag, False, True, False, False, "Send"], tag
+ # a re-entry (the retry after a failed upload) re-arms the full
+ # affordance — the helpers are idempotent in both directions.
+ assert by_tag["re-enter"] == ["re-enter", True, False, True, True, "Uploading…"]
+
+
+# =====================================================================
+# Phase 132 (task 02, TODO L8) — the strip's uploading CSS (the visual
+# half of the affordance: the dimmed thumbnail, the hidden name slot,
+# the spinning --ink-soft status, the dimmed remove ✕, the reduced-
+# motion still, and the verified house :disabled language on the Send)
+# =====================================================================
+
+
+def _phase132_css() -> str:
+ """The Phase 132 CSS block in styles.css — from its comment marker
+ to the next top-level rule (the ``.composer`` that follows the
+ ``.attach-preview*`` family) — with the comments stripped, so the
+ raw-color audit sees only declarations."""
+ css = _css()
+ start = css.find("/* Phase 132 (task 02, TODO L8):")
+ assert start != -1, "the Phase 132 CSS block must exist in styles.css"
+ end = css.find("\n.composer {", start)
+ assert end != -1, "the Phase 132 block must sit before the .composer rule"
+ return re.sub(r"/\*.*?\*/", "", css[start:end], flags=re.S)
+
+
+def test_the_uploading_strip_dims_the_thumbnail_and_keeps_the_name_slot() -> None:
+ """The in-flight strip: the thumbnail DIMS (opacity 0.5 — it is
+ decorative, no contrast duty), and the filename HIDES with
+ visibility (NOT display/width — the flex: 1 slot keeps its measured
+ space, so the status takes the row's right hand without reflowing
+ the pinned 44px remove button; the strip height is stable)."""
+ css = _css()
+ m = re.search(r"\.attach-preview\.is-uploading \.attach-preview-img \{(.*?)\}", css, re.S)
+ assert m, "the uploading thumbnail must dim"
+ assert "opacity: 0.5;" in m.group(1)
+ m = re.search(r"\.attach-preview\.is-uploading \.attach-preview-name \{(.*?)\}", css, re.S)
+ assert m, "the uploading name must hide"
+ body = m.group(1)
+ assert "visibility: hidden;" in body
+ assert "display: none" not in body and "width: 0" not in body, (
+ "the name slot must keep its space (visibility, not removal)"
+ )
+
+
+def test_the_uploading_status_row_is_the_ink_soft_small_text() -> None:
+ """The status part's row: the house inline-flex language (the icon
+ + the text as one unit), --ink-soft at the strip's small text size
+ (the name's 0.85rem), and min-width 0 (the row's clamp — at 320px
+ the text wraps rather than clipping; verified in the task's browser
+ pass: one line at 320px, no clip)."""
+ m = re.search(r"\.attach-preview-status \{(.*?)\}", _css(), re.S)
+ assert m, ".attach-preview-status must be styled"
+ body = m.group(1)
+ for needle in (
+ "display: inline-flex;",
+ "align-items: center;",
+ "gap: 0.35rem;",
+ "color: var(--ink-soft);",
+ "font-size: 0.85rem;",
+ "min-width: 0;",
+ ):
+ assert needle in body, f".attach-preview-status must keep: {needle}"
+
+
+def test_the_status_icon_is_the_14px_house_spin_at_the_sync_pace() -> None:
+ """The arc: 14px (the strip's small-text scale), flex 0 0 auto (the
+ .sync-icon house pattern), animated with the HOUSE `spin`
+ keyframes at 1s linear — .sync-icon.is-spinning's in-flight pace
+ (the .spinner's 0.8s is the busy-turn arc's). The keyframes must
+ EXIST — a dangling animation-name would spin nothing and the
+ "in motion" state would be invisible."""
+ m = re.search(r"\.attach-preview-status-icon \{(.*?)\}", _css(), re.S)
+ assert m, ".attach-preview-status-icon must be styled"
+ body = m.group(1)
+ for needle in ("width: 14px;", "height: 14px;", "flex: 0 0 auto;"):
+ assert needle in body, f".attach-preview-status-icon must keep: {needle}"
+ assert "animation: spin 1s linear infinite;" in body
+ assert re.search(r"@keyframes spin \{\s*to \{ transform: rotate\(360deg\); \}\s*\}", _css()), (
+ "the house `spin` keyframes must exist (the arc's animation-name)"
+ )
+
+
+def test_reduced_motion_stills_the_uploading_arc() -> None:
+ """The house reduced-motion block: with prefers-reduced-motion the
+ arc STILLS (animation none) — the "Uploading image…" text + the
+ dimmed thumbnail then carry the state without motion (B5: never
+ motion alone; the state is text + dimming, not the spin)."""
+ m = re.search(
+ r"@media \(prefers-reduced-motion: reduce\) \{\s*"
+ r"\.attach-preview-status-icon \{ animation: none; \}\s*\}",
+ _css(),
+ )
+ assert m, "the house reduced-motion block must still the uploading arc"
+
+
+def test_the_disabled_remove_x_dims_with_the_disabled_affordance() -> None:
+ """The remove ✕ (task 01 disables it mid-flight via the attribute)
+ dims to 0.5 while uploading — the attribute is the state, the
+ opacity is its visual half (the same 0.5 as the dimmed thumbnail,
+ the house dim language)."""
+ m = re.search(r"\.attach-preview\.is-uploading \.attach-preview-remove \{(.*?)\}", _css(), re.S)
+ assert m, "the uploading remove button must dim"
+ assert "opacity: 0.5;" in m.group(1)
+
+
+def test_the_disabled_send_rule_is_the_existing_house_rule_not_a_new_visual() -> None:
+ """Task 02's verification step: the Send is disabled via the
+ `disabled` attribute (task 01) and the EXISTING house rule
+ ``.send-btn:disabled`` already reads as disabled — the
+ --brand-busy background (the brand brightened 40% toward white,
+ visibly lighter than the enabled --brand fill, and a derived
+ variable that follows every saved theme) + the not-allowed
+ cursor. The pin says the rule is INTACT: the upload state must
+ not invent a second disabled look (no new color, no new weight).
+ (Verified in the task's browser pass: the rule applies to
+ #send-btn, which rides .send-btn.)"""
+ m = re.search(r"\.send-btn:disabled \{(.*?)\}", _css(), re.S)
+ assert m, "the house .send-btn:disabled rule must exist"
+ body = m.group(1)
+ assert "background: var(--brand-busy);" in body
+ assert "cursor: not-allowed;" in body
+
+
+def test_the_phase132_css_ships_no_raw_color_and_holds_the_aa_pair() -> None:
+ """The theme audit (the B4 byte-identical contract): every value
+ in the Phase 132 block is a theme variable or a neutral number —
+ NO raw hex/rgb/hsl (a custom theme repaints everything through the
+ variables). And the status text's --ink-soft on the strip's
+ --surface holds the AA floor: computed here against WCAG 2.1 from
+ the built-in :root literals (the theme phases' method) — 8.2:1,
+ ≥ 4.5:1."""
+ block = _phase132_css()
+ assert not re.search(r"#[0-9a-fA-F]{3,8}\b", block), "no raw hex in the Phase 132 block"
+ assert not re.search(r"\b(?:rgb|rgba|hsl|hsla)\(|\bsrgb\b", block), (
+ "no raw rgb/hsl in the Phase 132 block"
+ )
+ ratio = _wcag_contrast(_root_color("--ink-soft"), _root_color("--surface"))
+ assert ratio >= 4.5, f"--ink-soft on --surface = {ratio:.2f}:1 (< 4.5:1 AA floor)"
+
+
+def _root_color(name: str) -> str:
+ """A built-in :root literal color (the theme phases audit the
+ built-in palette this way; a saved theme repaints through the
+ variables and is checked by the theme E2E)."""
+ m = re.search(rf"(?m)^\s*{name}:\s*(#[0-9a-fA-F]{{6}})", _css())
+ assert m, f"{name} must ship a plain built-in color in :root"
+ return m.group(1)
+
+
+def _hex_rgb(value: str) -> tuple[int, int, int]:
+ h = value.lstrip("#")
+ return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
+
+
+def _rel_luminance(rgb: tuple[int, int, int]) -> float:
+ """WCAG 2.1 relative luminance (the house helper's math — the
+ test_responsive_polish / test_dark_tech_theme contrast helper)."""
+
+ def chan(c: int) -> float:
+ s = c / 255
+ return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
+
+ r, g, b = (chan(c) for c in rgb)
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
+
+
+def _wcag_contrast(fg: str, bg: str) -> float:
+ l1, l2 = _rel_luminance(_hex_rgb(fg)), _rel_luminance(_hex_rgb(bg))
+ if l1 < l2:
+ l1, l2 = l2, l1
+ return (l1 + 0.05) / (l2 + 0.05)