feat(web): move the chat action cluster to the pinned bottom and align the button sets

- task 01: relocate the .chat-actions row (New chat + Share, comments byte-identical with a Phase 65 note) from the top of the column to the bottom of .chat-shell, directly above the composer
- task 02 (owner-locked A1): wrap the row + #composer in ONE sticky .chat-bottom unit (position: sticky; bottom: env(safe-area-inset-bottom, 0), no z-index) — the pills stay at the bottom of the screen at every scroll position and settle into flow above the footer
- task 03 (owner-locked A2): right-align the bottom row to the column's right edge (justify-content: flex-end), mirroring the right-aligned Save-as-doc corner; the five action pills share one 44px / 999px-pill geometry
- task 04: dedicated Playwright suite tests/e2e/test_bottom_chat_actions.py (resting geometry, the A1 pin across the sticky range, A2 alignment + DOM order + mobile stack + 360px overflow bound + 44px touch targets, New chat / Share click-through) — green in isolation
- task 05: regression matrix green in isolation (pinned_composer 4, save_share_ux 5, chat_persistence 4, share_chat 4, chat_history 5, smoke 3); full gate green — unit + integration pass, app/ coverage 99% (>90%), ruff + pyright clean
This commit is contained in:
2026-09-02 01:08:18 -04:00
parent 4677d86f49
commit 8a1f99cb38
30 changed files with 1376 additions and 147 deletions
@@ -0,0 +1,49 @@
# Phase 64 — Real-Time Progress for Sync + Upload, Background Upload
**Source:** `TODO.md` L3 — "Need better indication of sync and upload progress. Both should show current file being processed in real time, not just 'syncing' or 'uploading'." (+ the navigate-away / toast half of the same item)
**Story:** n/a (TODO-derived — owner roadmap confirmation 2026-09-01)
**Context:** `app/api/sync.py` (phase 32 — background sync + the 2 s `GET /api/sync/status` polling pattern, the template for everything here), `app/api/git_sources.py` (phase 49 — the upload route whose scan runs **synchronously in the request**: the browser `fetch` blocks until unpack + import finish, so navigating away mid-upload aborts it — the exact defect this phase removes), `app/rag/importer.py` (`import_sources` loops file-by-file with **no** progress hook — the single place both flows can be instrumented), `frontend/assets/sources.js` (the sync button's §7.4 never-stale lifecycle + load-time re-attach), `frontend/assets/git-sources.js` (the upload form's "Uploading…" lifecycle), `frontend/assets/styles.css` (`.toast` — the phase-55 share-success toast, reused verbatim for the new "successfully uploaded" toast). E2E conventions: `tests/e2e/test_archive_upload_sources.py` (archive fixture builder, mock LLM, `BOR_UPLOAD_DIR` scratch) and `tests/e2e/test_sync_button.py`.
## Objective
Both long-running KB jobs report **which file is being processed right now** (not just "Syncing…"/"Uploading…"), and the archive upload is fully **backgrounded**: `POST /api/git-sources/upload` answers 202 the moment the archive is on disk (a "successfully uploaded" toast fires — the user may navigate away), the unpack/scan continues server-side with a status endpoint, and the sync button on the RAG page (`/sources.html`) animates with the upload's current file while that scan runs.
## Dependencies
- `63_unambiguous_document_listing` (complete) — the immediately preceding phase (the todo queue was empty at authoring; this phase builds on no unfinished work).
## Tasks
1. `01_importer_progress_hook.md` — optional per-file progress callback on `import_sources` (source, rel path, done/total).
2. `02_sync_status_current_file.md` — `SyncStatus` + `GET /api/sync/status` carry `current_file` (+ counts), wired through the hook.
3. `03_upload_background_202.md` — `POST /upload` → 202 + background task + `GET /api/git-sources/upload/status` (phase-32 pattern, incl. `current_file`).
4. `04_sync_button_live_file.md` — RAG-page sync button: live file label for sync runs AND for in-flight upload scans, catalog refresh + settle on upload completion, load-time re-attach.
5. `05_upload_toast_progress.md` — Sources-page upload UI: "successfully uploaded" toast on 202, live "Processing… <file>" label via status polling, 409/load-time re-attach, failure banner.
6. `06_e2e_sync_upload_progress.md` — the story Playwright suite + `test_archive_upload_sources.py` adaptation + regressions + commit.
## Testing & Quality
- Unit: the progress-hook contract in `tests/unit/test_importer.py`; status shapes in `tests/unit/test_sync_button.py`; the 202/background/upload-status contract in `tests/unit/test_archive_upload.py` (adapted from the synchronous expectations).
- Frontend source pins (house pattern): a new `tests/unit/test_frontend_sync_upload.py` — label builders, the polling decision trees, toast-on-202, re-attach paths.
- Coverage: **>90%** on `app/` (`validate.sh` gate).
- E2E (mandatory, A16): `tests/e2e/test_sync_upload_progress.py`, run in isolation; `test_archive_upload_sources.py` updated to the 202 + toast + polling flow; regressions `test_sync_button.py`, `test_git_sources_admin.py`, `test_sync_model_down.py` in isolation.
## Completion Criteria
- [ ] `GET /api/sync/status` and `GET /api/git-sources/upload/status` both report `current_file` / `files_done` / `files_total` while their job runs (null/0 idle).
- [ ] `POST /api/git-sources/upload` returns **202** with the safe source name once the archive is fully received; unpack + scan continue in a background task; one upload at a time (409 in flight).
- [ ] The "Successfully uploaded — <file>" toast appears on the Sources page at 202 (before the scan finishes); navigating to `/sources.html` mid-scan shows the sync button animating with the upload's current file; on completion the button settles and the catalog shows the new documents.
- [ ] The sync button's label shows the current file during sync runs ("Syncing… <file>") and during upload scans ("Importing <file>"); the upload area shows "Processing… <file>" during the scan.
- [ ] `uv run pytest` green; `app/` coverage >90%.
- [ ] `uv run pytest tests/e2e/test_sync_upload_progress.py -v --no-cov` green in isolation (DB up); `test_archive_upload_sources.py`, `test_sync_button.py`, `test_git_sources_admin.py`, `test_sync_model_down.py` green in isolation.
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
## Locked decisions
- **Owner-locked (2026-09-01, roadmap confirmation):**
- **A1 — the upload goes fully background.** 202 means "archive safely on disk"; unpack → swap → row upsert → model check → import → overview run in a background task; `GET /api/git-sources/upload/status` is the single source of truth (phase-32 `SyncStatus` pattern). One upload at a time stays (409). This is the only way "navigate away before it finishes" works.
- **A2 — toast timing/scope.** The "successfully uploaded" toast fires on the Sources page **at 202** (file received), auto-dismisses (~5 s, phase-55 `.toast`); failures are announced by the existing `#archive-upload-error` banner, never a toast.
- **A3 — RAG-page sync button during an upload scan.** It enters its running animation showing the upload's current file ("Importing <file>"); on upload completion it settles to "Sync sources" and the catalog refreshes (`loadDocs()`). The upload's count line itself stays on the Sources (git-sources) page — the sync-result line never renders upload counts.
- **A4 — progress granularity.** Per importable file: labels show `source/relative/path` (truncated with ellipsis; the full path lives in the button `title` + the aria-live result line). The unpack phase (before any file is indexed) shows no file yet — just "Processing…"/"Syncing…".
- **A5 — post-202 failures.** Unpack / zero-entry / swap / model / import failures land in the upload status as `failed` with a sanitized error (the `_sanitize_error` credential mask). Pre-swap failures leave the KB, folders, and rows untouched; post-swap failures keep the folder + row so a re-upload or the next sync retries idempotently (the existing phase-49 step-7 semantics, now in the status state instead of an HTTP error).
- **A9/A10/A16/A17 honoured** — the admin-only API surface stays stateless apart from the in-memory run state (phase 32 precedent); one story E2E suite; one atomic commit.
## Commit
```bash
git add -A .agent/ app/ frontend/ tests/ && git commit --no-gpg-sign -m "feat(sources): real-time file progress for sync and upload — background upload with success toast"
```
@@ -0,0 +1,40 @@
# Task 06 — E2E suite, regressions, commit
**Phase:** `64_sync_upload_progress` · **Source:** `TODO.md:3` — the whole item (the executable proof of every locked decision).
**Story:** n/a (TODO-derived)
## Objective
The story's Playwright suite proves the full contract end-to-end (toast on receive → navigate away → sync button animating with the upload's current file → catalog refreshed; live file labels on both jobs; reload re-attach), `test_archive_upload_sources.py` is brought onto the 202 contract, the regression suites stay green in isolation, and the phase is committed.
## Work
1. `tests/e2e/test_sync_upload_progress.py` — new suite (house conventions: `tests/e2e/conftest.py` + `mock_llm.py`, the admin sign-in and fixture style of `tests/e2e/test_git_sources_admin.py`, the archive builder + `BOR_UPLOAD_DIR` scratch of `tests/e2e/test_archive_upload_sources.py`, the local-source sync fixture style of `tests/e2e/test_sync_button.py`):
- **Fixture note (timing):** the mock LLM indexes fast — the in-progress state is real but brief. Build the upload archive from **20+ small `.md` files** so the scan outlasts the 2 s poll, and assert the live-file label at TWO layers: the deterministic one is the status endpoint (`page.request.get("/api/git-sources/upload/status")` / `"/api/sync/status"` — `state == "running"` with non-null `current_file` observed at some tick); the UI one polls the button label for the `Importing ` / `Syncing…` / `Processing…` prefix plus a file path (generous timeout).
- `test_upload_toast_then_navigate_away` — on `/git-sources.html`: pick the multi-file archive, submit → the `Successfully uploaded — <archive>` toast (`.toast.is-visible`, `role="status"`) appears **while the scan is still running**; immediately navigate to `/sources.html` → the sync button is present, animating (icon `is-spinning`, `aria-busy`) with the `Importing` label; wait for the settle → button idle ("Sync sources"), no error UI, and the catalog table shows the uploaded documents (the phase-63 listing is untouched).
- `test_upload_progress_shows_current_file` — during the scan: the status endpoint reports a non-null `current_file` (`source/relative/path` shape) at some running tick, and the upload button label shows `Processing…` with a file path (UI layer) before the result line (`fmtUploadResult` counts) lands; the toast fired earlier in the run (not after the result).
- `test_sync_live_file_label` — a multi-file local source configured (the `test_sync_button.py` fixture style): on `/sources.html` click **Sync sources** → the label shows `Syncing…` with a file path while running (endpoint layer: `/api/sync/status` `current_file` non-null; UI layer: label poll), then the success settle with the counts result line (the pre-phase-64 sync UX is preserved, plus the file).
- `test_upload_reattach_after_reload` — start the upload, and **during the scan** reload `/git-sources.html` → the button is in the Processing state (disabled) with **no** error banner and no second upload (the status endpoint's single run is still the one from before the reload); it then settles with the result line and the list shows exactly one row for the archive (in-place identity preserved).
2. `tests/e2e/test_archive_upload_sources.py` — adapt to the 202 contract (keep every scenario, move observation points):
- upload → the toast appears at 202, the button goes `Uploading…` → `Processing…` → restore; the result line + list now come from the status `success` (counts unchanged in shape).
- the inline 422/413 gates are unchanged (asserted exactly as today — name, format, cap, file kept).
- a bad-archive scenario (if present) now lands as the **error banner** via status `failed` instead of a 422 response; re-upload after it still works.
- the re-upload-in-place-replace scenario: the second run's status `detail` shows the prune/refresh counts; the list still has exactly ONE row for the archive name.
- anonymous: the gate/form stay hidden, `POST /api/git-sources/upload` 403, and `GET /api/git-sources/upload/status` 403 (new endpoint, same wall).
3. Run in isolation (DB up, the AGENTS.md rule-9 command): `test_sync_upload_progress.py` (new), `test_archive_upload_sources.py` (updated), `test_sync_button.py`, `test_git_sources_admin.py`, `test_sync_model_down.py`.
4. Full gate: `uv run pytest --cov=app --cov-report=term-missing` (suite green, `app/` >90%), `uv run ruff check . && uv run pyright`.
5. Commit + hand-off:
```bash
git add -A .agent/ app/ frontend/ tests/
git commit --no-gpg-sign -m "feat(sources): real-time file progress for sync and upload — background upload with success toast"
```
then move `.agent/phases/todo/64_sync_upload_progress/` → `.agent/phases/complete/64_sync_upload_progress/` (the pipeline's `validate.sh` gate is the move's precondition).
## Testing & Quality
- E2E: the new suite + the five isolated runs above are this phase's A16 gate.
- Coverage: **>90%** on `app/` (no new app code in this task — the gate guards against drift from tasks 01–03).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_sync_upload_progress.py -v --no-cov` green in isolation (DB up).
- [ ] `test_archive_upload_sources.py`, `test_sync_button.py`, `test_git_sources_admin.py`, `test_sync_model_down.py` green in isolation.
- [ ] `uv run pytest --cov=app` green with `app/` >90%; `uv run ruff check . && uv run pyright` clean.
- [ ] One `--no-gpg-sign` Conventional Commits commit; phase dir in `.agent/phases/complete/`.
- [ ] `TODO.md` contains only `# TODO` (cleared by the phase pipeline's conversion step — items now live here).
@@ -0,0 +1,28 @@
# Task 01 — Move the `.chat-actions` row to the bottom of the chat column
**Phase:** `65_bottom_chat_actions` · **Source:** `TODO.md:3` — "Move the new chat and share button to the tune/retry/save doc cluster area so it's always at the bottom of the screen and easily accessble…"
**Story:** n/a (TODO-derived)
## Objective
The New chat + Share row leaves the TOP of the column (where it scrolls off-screen the moment a conversation grows) and becomes the last child of `.chat-shell`, directly above the pinned composer — the same screen zone as the tune/retry/save-doc meta cluster. This task is the MOVE only (in-flow); the sticky pin lands in task 02 and the alignment in task 03.
## Work
1. `frontend/index.html`:
- Cut the ENTIRE `.chat-actions` block — the comment at L126–148 PLUS the `<div class="chat-actions">…</div>` at L149–187 (both buttons, every line of their comments included) — from between `#steering-announcer` (L129) and `<section class="messages" id="messages">` (L193).
- Paste it as the LAST child of `.chat-shell`: between the messages `</section>` (L207) and the composer comment block (L209), so each comment stays glued to its own element and the form (L225) is untouched. Indent to match the sibling elements (6 spaces); keep the blank-line separation between the four children.
- The two buttons stay BYTE-IDENTICAL: ids (`#new-chat-btn`, `#share-chat-btn`), classes, `aria-label`s, SVGs, the label spans, and the DOM order **New chat → Share** (pinned by `test_save_share_ux.py::test_action_row_layout`).
- Update the row's comment block in place (it moves with the row): keep the Phase 55/14/51 history, and APPEND a Phase 65 note — the row was relocated from the top of the column to the bottom (above the composer) per `TODO.md` L3 (owner confirmation 2026-09-01), so the cluster sits where the tune/retry/save-doc meta actions live; the top of the column is now banner → steering → announcer → messages.
2. No CSS changes in this task: the row's styling is position-independent (`.chat-actions` at `styles.css` L433; the ≤640px stack rule at L3049). As a column child it inherits `.chat-shell`'s `gap: 1rem` above (from `.messages`) and below (to the composer) — acceptable until task 02/03 set the wrapper gap.
3. No JS changes: every binding is id-based and position-independent — `frontend/assets/header.js` L336–339 (`#new-chat-btn` → `bor:new-chat` dispatch), `frontend/assets/app.js` L1700 (the listener), L246 + L2067 (`#share-chat-btn` → `shareCurrentChat`). The clipboard-fallback field already appends to the composer (`app.js` L1424), not the row.
4. Leave UNTOUCHED: `#kb-banner`, `#stale-banner`, `#steering-panel`, `#steering-announcer`, `#messages`, `#composer`, and every comment except the row's own moved block.
## Testing & Quality
- `uv run pytest` green (unit + integration — no Python changed, gate stays green); coverage TOTAL **>90%**; `uv run ruff check . && uv run pyright` clean.
- Highest-risk existing suite for this move: `uv run pytest tests/e2e/test_save_share_ux.py -v --no-cov` green in isolation (DB up) — it pins the row's geometry (single `.chat-actions`, DOM order, horizontal/stacked, intrinsic widths, 360px overflow) but nothing about its vertical position, so the move must not perturb it.
## Completion Criteria
- [ ] In `frontend/index.html`, the `.chat-actions` div appears AFTER `</section>` (the messages close, L207) and immediately before the composer form — it is the last child of `.chat-shell`; nothing remains at the old position (grep: exactly one `.chat-actions` in the file).
- [ ] `grep -n "new-chat-btn\|share-chat-btn" frontend/index.html` shows both buttons with unchanged ids/classes/aria-labels, New chat before Share.
- [ ] The row's comment block moved with the row and carries the Phase 65 relocation note.
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
- [ ] `uv run pytest tests/e2e/test_save_share_ux.py -v --no-cov` green in isolation (DB up).
@@ -0,0 +1,46 @@
# Task 02 — Pin the bottom cluster (`.chat-bottom` sticky unit)
**Phase:** `65_bottom_chat_actions` · **Source:** `TODO.md:3` — "…so it's always at the bottom of the screen and easily accessble."
**Story:** n/a (TODO-derived)
## Objective
The row + composer become ONE pinned unit (locked A1): at every scroll position inside the sticky range the bottom cluster sits flush with the viewport bottom — the pills are literally always at the bottom of the screen, even while the reader is scrolled up through a long conversation — and at the document bottom the cluster settles back into normal flow above the footer (never floating over it).
## Work
1. `frontend/index.html`:
- Wrap the `.chat-actions` div (task 01's position) AND the `<form class="composer">…</form>` (incl. its comment block) in a new `<div class="chat-bottom">` … `</div>` that is the single LAST child of `.chat-shell` (before the shell's closing `</div>`, currently L237). No id (nothing in JS binds it — the bindings are on the inner elements).
- Append a Phase 65 note to the row's comment (A1): the wrapper makes row + composer one sticky unit — `position: sticky; bottom: env(safe-area-inset-bottom, 0)`, NO z-index (the sticky header stays on top — the phase-46 stacking pinned by `test_pinned_composer.py`); the composer's OWN sticky declaration is kept (redundant inside the wrapper — its containing block is the wrapper, so it cannot shift — but `test_pinned_composer.py` asserts the computed style `sticky` on `#composer`).
2. `frontend/assets/styles.css` — new rule directly above the `.composer` rule (L1226):
```css
/* Phase 65 (owner-locked A1, 2026-09-01, TODO.md L3): the pinned
bottom cluster — the .chat-actions row + the #composer form as
ONE sticky unit ... (full house-style rationale: sticky range =
the .chat-shell containing block; the .messages flex-grow still
rests the unit at the screen bottom on short chats; no z-index,
so the phase-12 sticky header (z 20) and the phase-46 dropdown
always stay on top; the composer's own sticky rule stays —
redundant here, pinned by tests/e2e/test_pinned_composer.py). */
.chat-bottom {
display: flex;
flex-direction: column;
gap: 0.5rem;
position: sticky;
bottom: env(safe-area-inset-bottom, 0);
}
```
- `gap: 0.5rem` is the row↔composer spacing INSIDE the wrapper (the wrapper is one column child, so `.chat-shell`'s 1rem gap applies only ABOVE it, from `.messages`).
- The `#composer` rule itself is NOT modified (its `position: sticky; bottom: env(...)` stays — see the note above).
- The ≤640px block needs no change: the wrapper is a column flex box, so the stacked full-width pills (the existing L3049 `.chat-actions` rule) simply make the unit taller on phones.
3. Update the `.composer` rule's phase-52 comment minimally: the pin now belongs to `.chat-bottom`; the composer's own declaration is retained for the computed-style test pin (one line, no deletion of the phase-52 history).
4. Leave UNTOUCHED: `.messages` (`flex: 1 1 auto` + `min-height: 200px` — the grow that absorbs free space on short chats still works: the wrapper is the last child, so the grow pushes the WHOLE unit down), every z-index in the file, and all JS.
## Testing & Quality
- `uv run pytest` green; `uv run ruff check . && uv run pyright` clean; coverage TOTAL **>90%** (gate stays green — no Python changed).
- The load-bearing regression: `uv run pytest tests/e2e/test_pinned_composer.py -v --no-cov` green in isolation (DB up) — it re-verifies the whole pin contract WITH the wrapper: composer box flush with the viewport bottom at every scroll position, settled into flow above the footer at the document bottom, `#composer` computed `position === "sticky"` and `zIndex` in {auto, 0}, under the 64px header on a 375×812 phone, and the empty chat still not scrollable (`sh <= ch + 1` — the wrapper adds no height: the row already existed in the column, task 01).
- `uv run pytest tests/e2e/test_save_share_ux.py -v --no-cov` green in isolation (row geometry unchanged by the wrapper).
## Completion Criteria
- [ ] `frontend/index.html`: exactly one `.chat-bottom` div, the last child of `.chat-shell`, containing exactly `.chat-actions` then `#composer` (in that order).
- [ ] `frontend/assets/styles.css`: the `.chat-bottom` rule above `.composer` with `position: sticky; bottom: env(safe-area-inset-bottom, 0)` and NO z-index declaration; the `#composer` rule unmodified.
- [ ] Browser check via `test_pinned_composer.py` green in isolation — in particular `test_composer_pinned_at_every_scroll_position` (flush at 0/25/50/75% of the sticky range), `test_empty_chat_composer_sits_at_the_screen_bottom` (no scrollable space), `test_pin_holds_on_mobile_under_the_header` (no header overlap, no z-index).
- [ ] `test_save_share_ux.py` green in isolation.
@@ -0,0 +1,27 @@
# Task 03 — Align the two button clusters (neater, more aligned)
**Phase:** `65_bottom_chat_actions` · **Source:** `TODO.md:3` — "Make the button clusters look better, neater, more aligned"
**Story:** n/a (TODO-derived)
## Objective
The bottom row and the per-answer meta cluster read as ONE tidy, aligned system (locked A2): the New chat + Share row hugs the column's RIGHT edge on desktop — mirroring the right-aligned "Save as doc" action of every brain bubble above it — so the bottom-right of the chat is a single aligned action column. The five action pills share one geometry (≥44px targets, 999px radius, the global focus-visible ring).
## Work
1. `frontend/assets/styles.css` — the `.chat-actions` rule (L433): add `justify-content: flex-end;` so the row hugs the column's right edge on desktop. The pills keep their intrinsic widths (never stretched — `align-items: center` already beats the column default, and `justify-content` only shifts the group).
- The ≤640px block (L3049: `.chat-actions { flex-direction: column; align-items: stretch; gap: 0.5rem; }`) is UNCHANGED — with stretched full-width pills, horizontal alignment is moot (the stacked pills already edge-to-edge the column).
- Update the rule's comment: the row is the bottom cluster's top member (task 01/02), right-aligned per A2, and the vertical row↔composer spacing is the `.chat-bottom` wrapper's 0.5rem gap (task 02).
2. Five-pill geometry pass (VERIFY — fix only if a deviation exists; the expectation is "no declaration changes"):
- `.new-chat-btn` (L301) / `.share-chat-btn` (L335): solid brand family — `min-height: 44px`, `border-radius: 999px`, `border: 0`, 16px icons (hidden on desktop, icon-only ≤640px), `white-space: nowrap`.
- `.tune-btn` (L694) / `.retry-btn` (L723) / `.save-as-doc-btn` (L756): ghost family — `min-height: 44px`, `border-radius: 999px`, `padding: 0.35rem 0.8rem`, 14px icons, `margin-left/auto` right-alignment, `font-size: 0.82rem`.
- If any pill measures <44px tall at 360–1280px (deviation), bring it to the family's `min-height: 44px` here — do not invent new visual languages; the two families (solid = chat-level actions, ghost = per-answer actions) are intentional (phase 55 A2).
3. No HTML changes, no JS changes, no changes to the meta-row layout (`.msg-meta` L641 stays as-is — its internal order, chips-then-actions, is pinned by the phase 49/59 comment history).
4. No new CSS beyond the one declaration + comments.
## Testing & Quality
- `uv run pytest` green; `uv run ruff check . && uv run pyright` clean; coverage TOTAL **>90%** (gate stays green).
- `uv run pytest tests/e2e/test_save_share_ux.py -v --no-cov` green in isolation — its desktop assertions (Share right of New chat, both pills < column/2 wide, ONE `.chat-actions`) hold for a right-aligned row; the right-EDGE alignment itself is pinned by this phase's suite in task 04.
## Completion Criteria
- [ ] `.chat-actions` rule carries `justify-content: flex-end;` with the A2 rationale in its comment; the ≤640px stack rule is byte-identical.
- [ ] All five action pills (New chat, Share, Tune, Retry, Save as doc) report ≥44px rendered height at 360px, 390px, and 1280px widths (quick manual/Playwright measurement during the task; the standing pin lands in task 04).
- [ ] `test_save_share_ux.py` green in isolation (DB up).
@@ -0,0 +1,43 @@
# Task 04 — Dedicated E2E: `tests/e2e/test_bottom_chat_actions.py`
**Phase:** `65_bottom_chat_actions` · **Source:** `TODO.md:3` — "…so it's always at the bottom of the screen and easily accessble. Make the button clusters look better, neater, more aligned"
**Story:** n/a (TODO-derived)
## Objective
One dedicated Playwright suite (house rule: one file per phase, run in isolation) pins the NEW contract — the bottom cluster is always at the bottom of the screen, right-aligned, still stacked/clickable — so a future refactor cannot silently move the pills back to the top.
## Work
1. `tests/e2e/test_bottom_chat_actions.py` (NEW — self-contained, house patterns):
- Header docstring: Source `TODO.md` L3, run-in-isolation command `uv run pytest tests/e2e/test_bottom_chat_actions.py -v --no-cov` (DB up: `podman compose up -d db`), the owner-locked A1/A2 contracts, and the test→contract mapping (house "Playwright Mapping Rule").
- Fixtures/helpers: reuse the conftest `page` (1280×800), `app_url`, `mock_llm`, `db_ready`; copy the house `_reset_db` (TRUNCATE `chunks, documents, query_log, steering_notes, saved_chats`) + threaded `_import_fixtures` KB-seed pattern from `tests/e2e/test_pinned_composer.py` (13 fixture docs) into a `seeded_kb` fixture; `login` from `tests/e2e/auth_helpers.py` is NOT needed (chat is public).
- Test 1 `test_empty_chat_row_rests_at_screen_bottom` (no KB seed needed — static markup):
- fresh `/`: exactly ONE `.chat-bottom`, the row inside it; the document is NOT scrollable (`scrollHeight <= innerHeight + 1` — the wrapper must not invent scrollable space);
- the row's box is in the LOWER part of the viewport (row bottom ≥ 75% of the viewport height) and sits directly ABOVE the composer (row bottom ≤ composer top + 4px, no overlap);
- A2: the row's RIGHT edge aligns with the `.chat-shell` right edge (±2px);
- the composer's bottom is not clipped below the viewport.
- Test 2 `test_bottom_cluster_pinned_at_every_scroll_position` (`seeded_kb`, the `test_pinned_composer.py` overflow pattern — 6 short grounded turns from that file's `SHORT_QUESTIONS`):
- assert the overflow precondition (`scrollHeight > innerHeight + 200`);
- compute the sticky range from the `.chat-bottom`'s CONTAINING BLOCK (`.chat-shell` document-bottom, same math as `test_pinned_composer.py::test_composer_pinned_at_every_scroll_position`) and assert `pin_limit > 200` and `max_scroll > pin_limit`;
- at scroll y ∈ {0, 25%, 50%, 75%, pin_limit−1}: the `.chat-bottom` box is FULLY inside the viewport with its bottom flush with the viewport bottom (±4px) — the row AND the composer are visible at every reading position (this is the TODO's "always at the bottom");
- at the document bottom: the cluster settles into flow — NO overlap with `.app-footer`, the composer is no longer glued to the viewport edge (composer bottom < viewport bottom − 4px), and the row is still directly above the composer.
- Test 3 `test_row_geometry_and_alignment` (no conversation needed for the row; one seeded turn for `.retry-btn`):
- exactly one `.chat-actions` inside `.chat-bottom`; exactly one `#new-chat-btn` + one `#share-chat-btn` in it; DOM order New chat → Share (`compareDocumentPosition` — same check as `test_save_share_ux.py`);
- desktop 1280×800: one horizontal row (overlapping y-bands), Share right of New chat, each pill's width < column width/2 (intrinsic, never stretched), row right edge == `.chat-shell` right edge (±2px, A2);
- mobile 390×844: vertical stack — Share BELOW New chat, both pills the same width, each == the `.chat-bottom` content width (±2px, full-width stretch);
- 360×800: `document.documentElement.scrollWidth <= 360` (no horizontal overflow);
- touch targets: `#new-chat-btn`, `#share-chat-btn` (and, after one seeded turn, the injected `.retry-btn` on the last brain bubble) all render ≥44px tall at BOTH 1280 and 390 widths.
- Test 4 `test_buttons_still_work_from_the_bottom` (`seeded_kb`, one turn so a real conversation exists):
- `#new-chat-btn` click → `.msg` count 0, `#empty-state` visible, `localStorage["bor.chat.v1"]` null, `#send-status` contains "New chat started" (the header.js → `bor:new-chat` → app.js chain survived the move);
- on the now-empty chat, `#share-chat-btn` click → `#send-status` contains "Nothing to share yet.", NO `.toast`, URL unchanged (the empty-conversation no-op guard, `app.js` L1494–1497).
- Determinism: mock-only answers (`MOCK_ANSWER_MARKER`), settled-state assertions (`wait_settled` pattern — `#send-label` back to "Send"), no scroll calls from the app itself (the phase-42 never-auto-scroll contract — the test does all scrolling via `window.scrollTo`).
2. No other file changes in this task.
## Testing & Quality
- `uv run pytest tests/e2e/test_bottom_chat_actions.py -v --no-cov` green in isolation (DB up) — run it after EACH of the four tests is written, not all at once.
- `uv run pytest` green (unit + integration — no Python app changed); `uv run ruff check . && uv run pyright` clean (the new test file must pass both).
- Coverage TOTAL **>90%** (validate.sh gate — unchanged, no `app/` code).
## Completion Criteria
- [ ] The four tests exist, are named as specified, and are green in isolation (DB up).
- [ ] The suite asserts the A1 pin (wrapper flush at every scroll position), the A2 right-edge alignment, the mobile stack, the 360px overflow bound, the DOM order, and BOTH click-through contracts (New chat reset, Share no-op).
- [ ] Full suite green, lint + types clean, coverage >90%.