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:
2026-08-30 16:14:18 -04:00
parent 820753948e
commit aba8615177
7 changed files with 300 additions and 101 deletions
+34 -5
View File
@@ -404,6 +404,13 @@ html::after {
}
/* ---------- Main frame ---------- */
/* Phase 52 (owner revision 2026-08-30): `flex: 1` is load-bearing, not
cosmetic. Body is a `min-height: 100dvh` column flex box, so `main`
grows to the full viewport height and `.chat-shell` grows to fill
`main`. That full-height column is what makes the composer's sticky
shift range tall enough to hold the box on screen — the pin works in
BOTH directions only because of it (see `.composer` and `.messages`).
No `overflow` here: the document must stay the scroll container. */
.app-main {
flex: 1;
display: flex;
@@ -423,7 +430,20 @@ html::after {
flex: 1;
}
/* Phase 52 (owner revision 2026-08-30): `flex-grow` is the other half of
the pin. `position: sticky` only ever pulls a box UP toward the
viewport bottom — it can never push a box DOWN to meet it — so on an
empty/short chat the composer used to sit just under the empty state,
mid-screen, with a dead band between it and the footer. Growing the
message list absorbs that free space instead, so the composer's
resting (in-flow) position already IS the bottom of the screen. Once
the conversation is taller than the viewport there is no free space
left to absorb, `flex-grow` does nothing, and the `.composer` sticky
rule takes over. `flex-basis: auto` (not `1`) so the list keeps its
content height when the page overflows; the phase-01 `min-height`
floor stays; still no inner scroller (the document scrolls). */
.messages {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 0.9rem;
@@ -1140,12 +1160,21 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
fold while phase 42 forbids auto-scrolling a streaming reply — the
Stop control could literally not be reached ("it runs away from the
user as they try to click stop").
The pin is TWO rules working together — sticky alone is not enough
(owner revision 2026-08-30: "the chat message-input textarea should be
at the bottom of the screen"). `position: sticky` can only pull the
box UP to the viewport's bottom edge while the document overflows; it
never pushes it DOWN, so on an empty/short chat the box still sat
mid-screen. `.messages { flex: 1 1 auto }` supplies the missing half:
it puts the box's resting position at the bottom of the full-height
column, and sticky takes over the moment the conversation overflows.
The offset is the notch-aware safe-area inset (`env()` resolves to 0,
or falls back to 0 where unsupported, so the box sits flush with the
viewport bottom; ≤640px keeps its own
`main { padding-bottom: env(safe-area-inset-bottom) }`).
CSS-only by design: no DOM change, no JS, and NO new scroll call
site (the phase-42 never-auto-scroll contract stays intact —
`scrollReveal` is still the one page scroll in app.js). The offset
is the notch-aware safe-area inset (`env()` resolves to 0 on
desktop, so the box sits flush with the viewport bottom; ≤640px keeps
its own `main { padding-bottom: env(safe-area-inset-bottom) }`).
`scrollReveal` is still the one page scroll in app.js).
The solid `--surface` background, border, radius and shadow are kept
so messages scrolling behind the pinned box never show through it,
and no z-index is added: DOM order already paints the composer over
@@ -1156,7 +1185,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
align-items: flex-end;
gap: 0.6rem;
position: sticky;
bottom: env(safe-area-inset-bottom);
bottom: env(safe-area-inset-bottom, 0);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);