# 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" ```