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:
@@ -22,13 +22,16 @@ observed in a real browser:
|
||||
at all (the phase-42 never-auto-scroll contract — the pin adds no scroll
|
||||
call site), and a reload restores the ``stopped`` record through the
|
||||
normal ``bor.chat.v1`` path;
|
||||
* on an empty/short chat the composer renders in its normal flow position
|
||||
— the pin does not float it over the footer (``.app-footer`` stays in
|
||||
flow below it), and at the document bottom the box has settled back into
|
||||
flow above the footer instead of hovering on the viewport edge;
|
||||
* ≤640px the pin holds too (the composer is reachable, ≥44px Send target)
|
||||
and stays UNDER the sticky header — no z-index was added, so the pinned
|
||||
box can never cover the header or the hamburger dropdown (phase 46).
|
||||
* on an empty/short chat the composer renders in its RESTING position,
|
||||
which the grow puts at the BOTTOM of the screen — the only band below it
|
||||
is chrome (``.app-footer``, in normal flow, never overlapped), and at the
|
||||
document bottom the box has settled back into that same flow slot above
|
||||
the footer instead of hovering on the viewport edge;
|
||||
|
||||
* ≤640px the same two rules hold: the empty phone chat puts the input at
|
||||
the bottom of the screen and an over-viewport chat keeps it there, still
|
||||
UNDER the sticky header (no z-index was added, so the pinned box can
|
||||
never cover the bar or the hamburger dropdown — phase 46 stacking).
|
||||
|
||||
Determinism: the mock's grounded answers quote the question and end in the
|
||||
``Deterministic mock answer for E2E`` marker; the long answer (``write a
|
||||
@@ -41,7 +44,7 @@ of conversation into the 800px viewport.
|
||||
Test → story mapping (Playwright Mapping Rule):
|
||||
1. ``test_composer_pinned_at_every_scroll_position``
|
||||
2. ``test_stop_is_reachable_from_scrolled_up`` — the TODO.md L3 scenario
|
||||
3. ``test_empty_chat_composer_sits_in_normal_flow``
|
||||
3. ``test_empty_chat_composer_sits_at_the_screen_bottom``
|
||||
4. ``test_pin_holds_on_mobile_under_the_header`` — ≤640px stacking
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -100,6 +103,15 @@ FLUSH_PX = 4
|
||||
#: The sticky header's band (phase 12 pins it at 64px) — the pinned
|
||||
#: composer must never enter it.
|
||||
HEADER_PX = 64
|
||||
#: How much of the viewport may sit BELOW a resting composer as chrome:
|
||||
#: the footer's own measured height plus the flow padding the settled slot
|
||||
#: keeps (`main { padding-block: 1.25rem }` + the column's 1rem gap, with
|
||||
#: headroom for the safe-area inset). Anything more is wasted dead space —
|
||||
#: the empty chat must NOT leave the input hanging mid-screen.
|
||||
BOTTOM_SLACK_PX = 56
|
||||
#: The resting composer also has to be visibly in the lower part of the
|
||||
#: screen — the pre-revision geometry rested at ~57% of the viewport here.
|
||||
LOWER_PART = 0.8
|
||||
|
||||
# The typing indicator is itself a .msg.brain — exclude its bubble.
|
||||
ANSWER = ".msg.brain .bubble:not(.typing)"
|
||||
@@ -459,43 +471,74 @@ def test_stop_is_reachable_from_scrolled_up(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Natural bottom: an empty/short chat keeps the composer in its flow
|
||||
# position — the pin does not float it over the footer or shift the page
|
||||
# 3. Natural bottom: an empty/short chat RESTS the composer at the bottom
|
||||
# of the screen — no dead band under it, no float over the footer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_empty_chat_composer_sits_in_normal_flow(
|
||||
def assert_rests_at_the_screen_bottom(page: Page) -> dict[str, float]:
|
||||
"""The resting (in-flow) composer sits at the bottom of the screen.
|
||||
|
||||
This is the half `position: sticky` cannot deliver — sticky only pulls
|
||||
a box UP toward the viewport bottom while the document overflows, so on
|
||||
a page that does not scroll the box rested where the empty state ended
|
||||
(mid-screen) until `.messages` gained `flex: 1 1 auto`. The band below
|
||||
the box must be chrome only: the footer (measured live, so phones with
|
||||
the stacked footer are covered) plus the flow padding of the settled
|
||||
slot.
|
||||
"""
|
||||
ch = scroll_state(page)["ch"]
|
||||
composer = composer_box(page)
|
||||
bottom = composer["y"] + composer["height"]
|
||||
footer = box_of(page, ".app-footer")
|
||||
|
||||
assert bottom <= ch + FLUSH_PX, (
|
||||
f"the resting composer hangs below the viewport bottom "
|
||||
f"(bottom={bottom:.1f}, viewport={ch:.0f})"
|
||||
)
|
||||
assert ch - bottom <= footer["height"] + BOTTOM_SLACK_PX, (
|
||||
f"dead band under the composer: {ch - bottom:.0f}px below it with a "
|
||||
f"{footer['height']:.0f}px footer — the input is not at the bottom "
|
||||
"of the screen"
|
||||
)
|
||||
assert bottom >= ch * LOWER_PART, (
|
||||
f"the composer rests at {bottom / ch:.0%} of the viewport — it has "
|
||||
"to sit at the bottom, not under the empty state"
|
||||
)
|
||||
assert_no_overlap(composer, footer) # chrome, but never overlapped
|
||||
return composer
|
||||
|
||||
|
||||
def test_empty_chat_composer_sits_at_the_screen_bottom(
|
||||
page: Page, app_url: str, seeded_kb: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
page.goto(app_url)
|
||||
|
||||
# A fresh visitor: the empty state, nothing to scroll.
|
||||
# A fresh visitor: the empty state, nothing to scroll — and yet the
|
||||
# input is already at the bottom of the screen.
|
||||
expect(page.locator("#empty-state")).to_be_visible()
|
||||
state = scroll_state(page)
|
||||
assert state["sh"] <= state["ch"] + 1, (
|
||||
"an empty chat must not be scrollable — there is nothing to pin against"
|
||||
"an empty chat must not be scrollable — the grow must not invent "
|
||||
"scrollable space, it only redistributes the space the page has"
|
||||
)
|
||||
|
||||
composer = composer_box(page)
|
||||
assert composer["y"] + composer["height"] <= state["ch"] + FLUSH_PX, (
|
||||
"the composer may never hang below the viewport bottom"
|
||||
)
|
||||
assert_rests_at_the_screen_bottom(page)
|
||||
|
||||
# The footer is present, in normal flow, BELOW the composer: the sticky
|
||||
# box settles in its own flow slot (last child of .chat-shell) instead
|
||||
# of floating over the footer on a short page.
|
||||
footer = page.locator(".app-footer")
|
||||
expect(footer).to_be_visible()
|
||||
assert_no_overlap(composer, box_of(page, ".app-footer"))
|
||||
# The footer is present, in flow, BELOW the composer: the grow pushes
|
||||
# the composer down to its own flow slot, it does not float it over the
|
||||
# footer.
|
||||
expect(page.locator(".app-footer")).to_be_visible()
|
||||
|
||||
# A single short turn: still in flow, still nothing to scroll past.
|
||||
# A single short turn: still nothing to scroll past, still resting at
|
||||
# the bottom (the conversation has not overflowed the viewport yet).
|
||||
submit(page, SHORT_QUESTIONS[0])
|
||||
wait_settled(page)
|
||||
state = scroll_state(page)
|
||||
composer = composer_box(page)
|
||||
assert composer["y"] + composer["height"] <= state["ch"] + FLUSH_PX
|
||||
assert_no_overlap(composer, box_of(page, ".app-footer"))
|
||||
assert scroll_state(page)["sh"] <= scroll_state(page)["ch"] + 1, (
|
||||
"one short turn must not overflow the 800px test viewport"
|
||||
)
|
||||
assert_rests_at_the_screen_bottom(page)
|
||||
expect(page.locator(ANSWER).last).to_contain_text(MOCK_ANSWER_MARKER)
|
||||
|
||||
|
||||
@@ -513,6 +556,11 @@ def test_pin_holds_on_mobile_under_the_header(
|
||||
try:
|
||||
page.set_default_timeout(30_000)
|
||||
page.goto(app_url)
|
||||
|
||||
# An empty phone chat rests the input at the bottom of the screen.
|
||||
expect(page.locator("#empty-state")).to_be_visible()
|
||||
assert_rests_at_the_screen_bottom(page)
|
||||
|
||||
build_conversation(page, n=4)
|
||||
|
||||
state = scroll_state(page)
|
||||
@@ -521,6 +569,7 @@ def test_pin_holds_on_mobile_under_the_header(
|
||||
f"ch={state['ch']:.0f})"
|
||||
)
|
||||
|
||||
# …and an over-viewport one keeps it pinned there while reading.
|
||||
page.evaluate("() => window.scrollTo(0, 0)")
|
||||
assert page.evaluate(
|
||||
"() => document.querySelector('.chat-shell').getBoundingClientRect().bottom"
|
||||
|
||||
Reference in New Issue
Block a user