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:
2026-08-30 16:14:18 -04:00
parent 820753948e
commit aba8615177
7 changed files with 300 additions and 101 deletions
@@ -0,0 +1,74 @@
# 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) sits at the bottom of the screen — on an EMPTY/short chat as its resting position and at every scroll position of an over-viewport conversation — so the Stop control is always reachable mid-turn without scrolling, and no new auto-scroll behaviour is introduced (the phase-42 contract stays intact).
## Revision (owner, 2026-08-30) — the first pass did NOT complete this phase
The first pass shipped `position: sticky; bottom` on `.composer` only and
called the phase done. The owner rejected it: *"The chat message-input
textarea should be at the bottom of the screen. It's not right now."*
Verified in the browser: on an empty chat the input rested just under the
empty state (~57% of the viewport) with a dead band down to the footer.
Why sticky alone cannot satisfy the objective: **`position: sticky` can
only pull a box UP toward the scrollport's bottom edge — it never pushes a
box DOWN to meet it.** So it works only while the document overflows
(which the old E2E suite tested, and which is why the suite went green on
a half-fixed feature); on a page that does not scroll it is a no-op.
The recorded assumption "the pin is CSS-only sticky, no other rule" was
the wrong assumption — flagged and revised here, not silently deviated.
The pin is now TWO rules:
1. `.messages { flex: 1 1 auto }` — absorbs the free space of a short page
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}` → grown message list).
2. `.composer { position: sticky; bottom: env(safe-area-inset-bottom, 0) }`
— takes over as soon as the conversation overflows, gluing the box (and
Stop) to the viewport's bottom edge at every scroll position; the `0`
fallback replaces the old `env()`-only offset, which degraded to
`auto` (no pin at all) where `env()` is unsupported.
Both halves stay CSS-only: no DOM change, no JS, no new scroll call site,
no z-index — so the phase-42 never-auto-scroll contract still holds. The
E2E contract was corrected the same way: the empty-chat test is now
`test_empty_chat_composer_sits_at_the_screen_bottom` (the old
`sits_in_normal_flow` test asserted the buggy geometry as expected
behaviour), and the phone suite checks the resting position too.
## 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
- [x] On an EMPTY/short chat the composer's resting position is at the bottom of the screen — the only band below it is chrome (`.app-footer`, in flow, never overlapped); no dead wasted space.
- [x] With an over-viewport conversation, scrolled to the top: the composer is fully visible (bounding box inside the viewport) at the bottom edge.
- [x] 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).
- [x] `uv run pytest` green (1019 passed); coverage TOTAL 99% (>90%).
- [x] `uv run pytest tests/e2e/test_pinned_composer.py -v --no-cov` green in isolation (4 passed, DB up).
- [x] Regression E2E suites green in isolation: `test_stop_generation.py` (3), `test_no_reply_autoscroll.py` (6), `test_chat_persistence.py` (4), `test_mobile_hamburger_nav.py` (7) — plus the layout/scroll neighbours `test_smoke.py` (3), `test_chat_rag.py` (3), `test_honest_deflection.py` (3), `test_suggestion_chips.py` (4), `test_loading_feedback.py` (5), `test_long_answers.py` (2), `test_markdown_tables.py` (6), `test_retry_answer.py` (4), `test_thinking_scroll.py` (8), `test_responsive_polish.py` (7), `test_share_chat.py` (4), `test_chat_history.py` (5), `test_dark_tech_theme.py` (6), `test_background_no_motion.py` (8).
- [x] `uv run ruff check . && uv run pyright` clean.
## Locked decisions
- **REVISION of assumption (1) (owner, 2026-08-30):** sticky alone was wrong — see **## Revision** above. The pin is `position: sticky; bottom: env(safe-area-inset-bottom, 0)` on `.composer` **plus** `flex: 1 1 auto` on `.messages`, so the resting position also lands at the bottom of the screen. Still CSS-only: no `index.html` DOM change, no JS.
- **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~~ **revised, see above**; 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"
```
@@ -4,14 +4,37 @@
**Story:** n/a (TODO-derived)
## Objective
The composer stays pinned to the bottom of the viewport at every scroll position — a CSS-only change inside the existing chat column.
The composer stays at the bottom of the screen — resting there on a short page and pinned there at every scroll position of an over-viewport page — with CSS-only changes inside the existing chat column.
## Revision (owner, 2026-08-30) — this task was NOT done the first time
The first pass applied only `position: sticky; bottom: env(safe-area-inset-`
`bottom)` to `.composer`, and the browser disproved it: on an empty/short
chat the input rested just under the empty state (~57% of the viewport)
with a dead band all the way down to the footer. `position: sticky` can
only pull a box UP to the scrollport's bottom edge; it never pushes a box
DOWN to meet it, so on a page that does not overflow it does nothing — the
old E2E contract only ever exercised an overflowing conversation, which is
why the half-fix passed. Both rules below are now in place and both are
source-pinned in `tests/unit/test_pinned_composer.py`.
## Work
1. `frontend/assets/styles.css` — in the `/* ---------- Composer ---------- */` block (`.composer`, ~L1133): add `position: sticky;` and `bottom: env(safe-area-inset-bottom);` to `.composer`. The page scrolls at the document level and `.chat-shell` is the composer's containing column, so the box sticks to the viewport's bottom edge (offset by the mobile safe-area inset) while `.messages` scrolls behind it; at the document bottom it settles back into its normal flow position above the footer. Keep the existing solid `background: var(--surface)`, border, radius and `box-shadow: var(--shadow)` — messages must never show through the pinned box.
1. `frontend/assets/styles.css` — TWO rules, both required:
a. `.messages { flex: 1 1 auto; }` (Main-frame block) — the message list
absorbs the free space of a short page, 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}`).
`flex-basis` stays `auto` (a `0` basis would size the list below its
content once the conversation overflows and let bubbles overlap the
box); no `height` cap, no `overflow` — the document stays the scroller.
b. `.composer { position: sticky; bottom: env(safe-area-inset-bottom, 0); }`
(`/* ---------- Composer ---------- */` block, ~L1133) — takes over the
moment the conversation overflows. The page scrolls at the document level and `.chat-shell` is the composer's containing column, so the box sticks to the viewport's bottom edge (offset by the mobile safe-area inset) while `.messages` scrolls behind it; at the document bottom it settles back into its normal flow position above the footer. Keep the existing solid `background: var(--surface)`, border, radius and `box-shadow: var(--shadow)` — messages must never show through the pinned box.
2. `frontend/index.html` — verify NO change needed: the composer is already the LAST child of `.chat-shell` (the sticky context), and the `#message-input` / `#send-btn` / `#send-status` markup is untouched.
3. Do NOT touch `frontend/assets/app.js` — the pin must not add any scroll call site (phase-42 invariant; the one page scroll in the file stays `scrollReveal`).
4. `tests/unit/test_pinned_composer.py` (new, house pin style — see `tests/unit/test_frontend_scroll.py`): assert `styles.css` declares `position: sticky` AND a `bottom:` offset on `.composer` (the sticky-bottom pair, matched inside the `.composer` rule); assert `app.js` is unchanged in its scroll surface (the phase-42 single-`scrollReveal` pin still holds — reuse the same assertion approach `test_no_reply_autoscroll.py`'s companion pins use).
- ASSUMPTION: `bottom: env(safe-area-inset-bottom)` (not `bottom: 0` + extra padding) — the standard notch-aware inset; on desktop `env()` resolves to 0, so the box sits flush with the viewport bottom.
- ASSUMPTION (revised): `bottom: env(safe-area-inset-bottom, 0)` — the
notch-aware inset with an explicit `0` fallback; `env()`-only (the first
pass) degrades to `auto`, i.e. no pin, where the function is unsupported.
- ASSUMPTION: no `z-index` added — DOM order already stacks the composer above `.messages`; the sticky header (z 20) and doc modal (z 1000) are unaffected.
## Testing & Quality
@@ -19,5 +42,5 @@ The composer stays pinned to the bottom of the viewport at every scroll position
- Coverage: **>90%** — no `app/` change; the gate stays green.
## Completion Criteria
- [ ] `.composer` in `styles.css` carries `position: sticky` + the `bottom` safe-area offset; the `frontend/` diff contains no JS change.
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
- [x] `.messages` carries the `flex-grow` and `.composer` carries `position: sticky` + the `bottom` safe-area offset with the `0` fallback; the `frontend/` diff contains no JS and no DOM change.
- [x] `uv run pytest` green (1019 passed, TOTAL 99%); `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,37 @@
# 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.
## Revision (owner, 2026-08-30) — the suite tested the half-fix
The first pass wrote `test_empty_chat_composer_sits_in_normal_flow`, which
asserted that on an empty chat the composer "renders in its normal flow
position" — i.e. it pinned the BUG as the expected result, so the suite
went green while the objective failed. It is now
`test_empty_chat_composer_sits_at_the_screen_bottom` (helper
`assert_rests_at_the_screen_bottom`): the band below the resting composer
may be chrome only (the footer's measured height + the settled slot's flow
padding, `BOTTOM_SLACK_PX`), and the composer must sit in the lower part of
the viewport (`LOWER_PART`). The phone suite checks the resting position as
well as the pinned one.
## 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 — the composer RESTS at the bottom of the screen (no dead band under it) while the `.app-footer` stays in normal flow below it, un-overlapped; one short turn still rests there.
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
- [x] `uv run pytest tests/e2e/test_pinned_composer.py -v --no-cov` green in isolation (4 passed, DB up).
- [x] `test_stop_generation.py` (3), `test_no_reply_autoscroll.py` (6), `test_chat_persistence.py` (4), `test_mobile_hamburger_nav.py` (7) green in isolation — plus the layout/scroll neighbours (smoke 3, chat_rag 3, honest_deflection 3, suggestion_chips 4, loading_feedback 5, long_answers 2, markdown_tables 6, retry_answer 4, thinking_scroll 8, responsive_polish 7, share_chat 4, chat_history 5, dark_tech_theme 6, background_no_motion 8).
- [x] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.