Files
brain-of-reese/.agent/phases/complete/52_pinned_composer/01_sticky_composer.md
T
ducoterra aba8615177 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.
2026-08-30 16:14:18 -04:00

4.1 KiB

Task 01 — Sticky Bottom Composer

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"" Story: n/a (TODO-derived)

Objective

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 — 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 (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

  • Unit: tests/unit/test_pinned_composer.py (the pins above) — uv run pytest tests/unit/test_pinned_composer.py -v green.
  • Coverage: >90% — no app/ change; the gate stays green.

Completion Criteria

  • .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.
  • uv run pytest green (1019 passed, TOTAL 99%); uv run ruff check . && uv run pyright clean.