fix(chat): rest the composer at the viewport bottom — sticky alone left it mid-screen
Phase 52's first pass shipped `position: sticky; bottom` on `.composer` and
called the phase done, but the owner's requirement — "the chat message-input
textarea should be at the bottom of the screen" — still failed in the browser:
on an empty/short chat the input rested just under the empty state (~57% of
the viewport) with a dead band down to the footer.
`position: sticky` can only pull a box UP toward the scrollport's bottom edge;
it can never push a box DOWN to meet it, so on a page that does not overflow
it is a no-op. The old story suite only exercised an overflowing conversation
(one test even asserted the buggy resting position as expected), which is why
the half-fix passed.
- `.messages { flex: 1 1 auto }` — absorbs a short page's free space so the
composer's resting in-flow position is the bottom of the full-height column
(body min-height:100dvh -> .app-main flex:1 -> .chat-shell flex:1); basis
stays `auto`, no height cap, no overflow — the document stays the scroller
- `.composer { bottom: env(safe-area-inset-bottom, 0) }` — the explicit 0
fallback replaces the env()-only offset, which degraded to `auto` (no pin)
wherever env() is unsupported
- E2E: `test_empty_chat_composer_sits_in_normal_flow` ->
`..._at_the_screen_bottom` (chrome-only band below the resting composer);
the phone suite now checks the resting position as well as the pinned one
- Unit pins: the flex-grow half and the full-height column are pinned, so the
fix cannot silently regress to sticky-only
Still CSS-only — no DOM change, no JS, no new scroll call site (phase 42
never-auto-scroll contract intact), no z-index.
Verified: 1019 unit/integration tests pass (app/ coverage 99%), ruff and
pyright clean; tests/e2e/test_pinned_composer.py green in isolation (4), plus
the stop/autoscroll/persistence/mobile-nav suites and 14 layout/scroll
neighbours green in isolation.
This commit is contained in:
@@ -1,41 +0,0 @@
|
||||
# Phase 52 — Pinned Message Composer
|
||||
|
||||
**Source:** `TODO.md` L3 — "The message input text box needs to be pinned to the bottom of the screen so it doesn't \"run away\" from the user as they try to click \"stop\""
|
||||
**Story:** n/a (TODO-derived — owner instruction 2026-08-30: convert without confirmation)
|
||||
**Context:** The chat page (`frontend/index.html`) scrolls at the document level: `.chat-shell` (the centered 46rem column, PLAN §7) is a flex column — kb-banner, steering panel, New chat, Save/Share, `.messages`, and finally the `.composer` form (`#message-input` + `#send-btn`). The composer is NOT sticky — in a long conversation it sits below the fold, and since the page never auto-scrolls while a turn streams (phase 42), the Stop button (phase 48: `#send-btn` morphs into the enabled Stop control in flight) can be off-screen exactly when the user wants to click it. The sticky app header is the only sticky chrome (z-index 20, 2px hairline below); the document modal is the topmost layer (z-index 1000). House frontend testing: source pins (`tests/unit/test_frontend_feedback.py` style — `test_frontend_scroll.py` / `test_history_page.py` are the closest precedents) plus one isolated Playwright suite per story (A16).
|
||||
|
||||
## Objective
|
||||
The composer (input + Send/Stop button) is pinned to the bottom of the viewport at every scroll position — the Stop control is always reachable mid-turn without scrolling, and no new auto-scroll behaviour is introduced (the phase-42 contract stays intact).
|
||||
|
||||
## Dependencies
|
||||
- `48_stop_generation` (complete) — the Send↔Stop morph; Stop is clicked FROM the pinned composer (the original "run away" scenario).
|
||||
- `42_no_reply_autoscroll` (complete) — the no-autoscroll-while-streaming contract the pin must not revise.
|
||||
- `07_story_responsive_polish` (complete) — the 46rem column / responsive rules the pinned composer must sit within.
|
||||
|
||||
## Tasks
|
||||
1. `01_sticky_composer.md` — the `position: sticky; bottom` pin on `.composer` + safe-area inset + the frontend source pins.
|
||||
2. `02_e2e_pinned_composer.md` — the story Playwright suite + regressions + commit.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_pinned_composer.py` — source pins: `.composer` carries `position: sticky` with a `bottom` offset (safe-area inset) in `styles.css`; `app.js` gains NO new page-scroll call site (the phase-42 invariant — the one page scroll is still `scrollReveal`).
|
||||
- Coverage: **>90%** on `app/` (validate.sh gate — this phase makes no `app/` changes; the gate must stay green).
|
||||
- E2E (mandatory, A16): `tests/e2e/test_pinned_composer.py`, run in isolation.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] With an over-viewport conversation, scrolled to the top: the composer is fully visible (bounding box inside the viewport) at the bottom edge.
|
||||
- [ ] In flight, scrolled up to read earlier content: the Stop button is visible and clickable; clicking it (no scrolling) stops the turn — partial kept + persisted with `stopped: true` (the phase-48 contract, unchanged), no error banner, no window scroll (phase 42).
|
||||
- [ ] On an empty/short chat the composer renders in its normal flow position (the pin does not float it over the footer or shift the layout).
|
||||
- [ ] `uv run pytest` green; coverage TOTAL >90%.
|
||||
- [ ] `uv run pytest tests/e2e/test_pinned_composer.py -v --no-cov` green in isolation (DB up).
|
||||
- [ ] Regression E2E suites green in isolation: `test_stop_generation.py`, `test_no_reply_autoscroll.py`, `test_chat_persistence.py`, `test_mobile_hamburger_nav.py`.
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||
|
||||
## Locked decisions
|
||||
- **Recorded assumptions (TODO conversion, 2026-08-30 — owner asked for no confirmation):** (1) the pin is CSS-only — `position: sticky; bottom: env(safe-area-inset-bottom)` on the existing `.composer` inside the existing `.chat-shell` column; no `index.html` DOM change, no JS; (2) the composer keeps its current solid `--surface` background + border + shadow (no glass/transparency), so scrolled messages never show through it; (3) NO z-index change — the composer already paints above `.messages` by DOM order, never overlaps the sticky header, and stays under the z-1000 document modal; (4) the phase-42 never-auto-scroll contract is strictly upheld — the pin adds zero scroll call sites.
|
||||
- **A16/A17 honoured** — one story E2E suite, one atomic commit.
|
||||
|
||||
## Commit
|
||||
```bash
|
||||
git add -A .agent/ frontend/ tests/ && git commit --no-gpg-sign -m "feat(chat): pin the composer to the viewport bottom — Stop is always reachable while reading"
|
||||
```
|
||||
@@ -1,25 +0,0 @@
|
||||
# Task 02 — E2E: Pinned Composer + Regressions + Commit
|
||||
|
||||
**Phase:** `52_pinned_composer` · **Source:** `TODO.md:3` — "The message input text box needs to be pinned to the bottom of the screen so it doesn't \"run away\" from the user as they try to click \"stop\"" (this task verifies it in the browser)
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
One isolated Playwright story suite proving the composer never runs away from the user — including the original scenario: clicking **Stop** while reading a streaming answer from a scrolled-up position.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_pinned_composer.py` (new) — the story suite (isolated run; `mock_llm` deterministic; DB up per the e2e prerequisite):
|
||||
- **Pinned while reading:** build an over-viewport conversation — ask ~8 short questions through the UI (each turn adds user + brain bubbles with meta rows; at the house 1280×720 viewport this exceeds the fold; see the ASSUMPTION below for the fallback if it proves insufficient). `page.evaluate("window.scrollTo(0, 0)")` (a test scroll — the app never scrolls itself, phase 42). Assert `page.locator("#composer").bounding_box()` is fully inside the viewport (`y >= 0`, `y + height <= viewport height`) with its bottom edge at the viewport bottom (± a few px for the safe-area inset).
|
||||
- **The run-away scenario — Stop from scrolled-up, in flight:** submit one question; let the turn enter streaming (the mock LLM streams deltas; wait for the Send label to read "Stop" per the phase-48 contract); scroll the window to the top (the user reads earlier content — phase 42 leaves them there; record `window.scrollY`); assert the Stop button (`#send-btn`, `.is-stop`) is visible WITHOUT scrolling; click it; assert: the turn settled (no in-flight state), the partial answer is on screen with the `.stopped-note` rendered, no error banner, `window.scrollY` UNCHANGED by the click (the pin adds no scroll), and a fresh page load restores the `stopped` record (the phase-48 persistence contract through the normal `bor.chat.v1` path).
|
||||
- **Natural bottom:** a fresh empty chat — `#composer`'s bounding box bottom is at or above the viewport bottom and the `.app-footer` is present in normal flow (the pin must not float the composer over the footer on a short page).
|
||||
2. Regressions, each in isolation (`uv run pytest tests/e2e/<file> -v --no-cov`): `test_stop_generation.py`, `test_no_reply_autoscroll.py`, `test_chat_persistence.py`, `test_mobile_hamburger_nav.py` (the mobile nav sits in the sticky header — the pin must not break the header/dropdown stacking at ≤640px).
|
||||
3. One `--no-gpg-sign` commit staging `.agent/ frontend/ tests/` (message per the phase overview); move `.agent/phases/todo/52_pinned_composer/` to `.agent/phases/complete/`.
|
||||
- ASSUMPTION: over-viewport overflow is produced by ~8 UI questions against the mock LLM (short deterministic answers, but each turn adds two bubbles + meta rows). If the suite shows that is not enough to exceed 720px, fall back to a saved long conversation via the phase-50 path (Save a multi-turn chat, reload with `/?chat=<id>`); no new fixture or API surface.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E (mandatory, A16): `tests/e2e/test_pinned_composer.py` green in isolation.
|
||||
- The four regression suites green in isolation (no assertion edits outside the scope the phase-48 revised contract already owns — if `test_stop_generation.py` needs a revision it must be the pinned-composer contract, nothing else).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_pinned_composer.py -v --no-cov` green in isolation (DB up).
|
||||
- [ ] `test_stop_generation.py`, `test_no_reply_autoscroll.py`, `test_chat_persistence.py`, `test_mobile_hamburger_nav.py` green in isolation.
|
||||
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||
Reference in New Issue
Block a user