Files
brain-of-reese/.agent/phases/complete/52_pinned_composer/00_phase.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

7.4 KiB

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

  • 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.
  • 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).
  • uv run pytest green (1019 passed); coverage TOTAL 99% (>90%).
  • uv run pytest tests/e2e/test_pinned_composer.py -v --no-cov green in isolation (4 passed, DB up).
  • 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).
  • 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

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"