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.
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
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-basisstaysauto(a0basis would size the list below its content once the conversation overflows and let bubbles overlap the box); noheightcap, nooverflow— 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-shellis the composer's containing column, so the box sticks to the viewport's bottom edge (offset by the mobile safe-area inset) while.messagesscrolls behind it; at the document bottom it settles back into its normal flow position above the footer. Keep the existing solidbackground: var(--surface), border, radius andbox-shadow: var(--shadow)— messages must never show through the pinned box.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-statusmarkup is untouched.- 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 staysscrollReveal). tests/unit/test_pinned_composer.py(new, house pin style — seetests/unit/test_frontend_scroll.py): assertstyles.cssdeclaresposition: stickyAND abottom:offset on.composer(the sticky-bottom pair, matched inside the.composerrule); assertapp.jsis unchanged in its scroll surface (the phase-42 single-scrollRevealpin still holds — reuse the same assertion approachtest_no_reply_autoscroll.py's companion pins use).
- ASSUMPTION (revised):
bottom: env(safe-area-inset-bottom, 0)— the notch-aware inset with an explicit0fallback;env()-only (the first pass) degrades toauto, i.e. no pin, where the function is unsupported. - ASSUMPTION: no
z-indexadded — 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 -vgreen. - Coverage: >90% — no
app/change; the gate stays green.
Completion Criteria
.messagescarries theflex-growand.composercarriesposition: sticky+ thebottomsafe-area offset with the0fallback; thefrontend/diff contains no JS and no DOM change.uv run pytestgreen (1019 passed, TOTAL 99%);uv run ruff check . && uv run pyrightclean.