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:
@@ -0,0 +1,74 @@
|
||||
# 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
|
||||
- [x] 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.
|
||||
- [x] With an over-viewport conversation, scrolled to the top: the composer is fully visible (bounding box inside the viewport) at the bottom edge.
|
||||
- [x] 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).
|
||||
- [x] `uv run pytest` green (1019 passed); coverage TOTAL 99% (>90%).
|
||||
- [x] `uv run pytest tests/e2e/test_pinned_composer.py -v --no-cov` green in isolation (4 passed, DB up).
|
||||
- [x] 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).
|
||||
- [x] `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
|
||||
```bash
|
||||
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"
|
||||
```
|
||||
@@ -4,14 +4,37 @@
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
The composer stays pinned to the bottom of the viewport at every scroll position — a CSS-only change inside the existing chat column.
|
||||
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` — in the `/* ---------- Composer ---------- */` block (`.composer`, ~L1133): add `position: sticky;` and `bottom: env(safe-area-inset-bottom);` to `.composer`. 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.
|
||||
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: `bottom: env(safe-area-inset-bottom)` (not `bottom: 0` + extra padding) — the standard notch-aware inset; on desktop `env()` resolves to 0, so the box sits flush with the viewport bottom.
|
||||
- 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
|
||||
@@ -19,5 +42,5 @@ The composer stays pinned to the bottom of the viewport at every scroll position
|
||||
- Coverage: **>90%** — no `app/` change; the gate stays green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `.composer` in `styles.css` carries `position: sticky` + the `bottom` safe-area offset; the `frontend/` diff contains no JS change.
|
||||
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||
- [x] `.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.
|
||||
- [x] `uv run pytest` green (1019 passed, TOTAL 99%); `uv run ruff check . && uv run pyright` clean.
|
||||
|
||||
+16
-4
@@ -6,11 +6,23 @@
|
||||
## Objective
|
||||
One isolated Playwright story suite proving the composer never runs away from the user — including the original scenario: clicking **Stop** while reading a streaming answer from a scrolled-up position.
|
||||
|
||||
## Revision (owner, 2026-08-30) — the suite tested the half-fix
|
||||
The first pass wrote `test_empty_chat_composer_sits_in_normal_flow`, which
|
||||
asserted that on an empty chat the composer "renders in its normal flow
|
||||
position" — i.e. it pinned the BUG as the expected result, so the suite
|
||||
went green while the objective failed. It is now
|
||||
`test_empty_chat_composer_sits_at_the_screen_bottom` (helper
|
||||
`assert_rests_at_the_screen_bottom`): the band below the resting composer
|
||||
may be chrome only (the footer's measured height + the settled slot's flow
|
||||
padding, `BOTTOM_SLACK_PX`), and the composer must sit in the lower part of
|
||||
the viewport (`LOWER_PART`). The phone suite checks the resting position as
|
||||
well as the pinned one.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_pinned_composer.py` (new) — the story suite (isolated run; `mock_llm` deterministic; DB up per the e2e prerequisite):
|
||||
- **Pinned while reading:** build an over-viewport conversation — ask ~8 short questions through the UI (each turn adds user + brain bubbles with meta rows; at the house 1280×720 viewport this exceeds the fold; see the ASSUMPTION below for the fallback if it proves insufficient). `page.evaluate("window.scrollTo(0, 0)")` (a test scroll — the app never scrolls itself, phase 42). Assert `page.locator("#composer").bounding_box()` is fully inside the viewport (`y >= 0`, `y + height <= viewport height`) with its bottom edge at the viewport bottom (± a few px for the safe-area inset).
|
||||
- **The run-away scenario — Stop from scrolled-up, in flight:** submit one question; let the turn enter streaming (the mock LLM streams deltas; wait for the Send label to read "Stop" per the phase-48 contract); scroll the window to the top (the user reads earlier content — phase 42 leaves them there; record `window.scrollY`); assert the Stop button (`#send-btn`, `.is-stop`) is visible WITHOUT scrolling; click it; assert: the turn settled (no in-flight state), the partial answer is on screen with the `.stopped-note` rendered, no error banner, `window.scrollY` UNCHANGED by the click (the pin adds no scroll), and a fresh page load restores the `stopped` record (the phase-48 persistence contract through the normal `bor.chat.v1` path).
|
||||
- **Natural bottom:** a fresh empty chat — `#composer`'s bounding box bottom is at or above the viewport bottom and the `.app-footer` is present in normal flow (the pin must not float the composer over the footer on a short page).
|
||||
- **Natural bottom:** a fresh empty chat — the composer RESTS at the bottom of the screen (no dead band under it) while the `.app-footer` stays in normal flow below it, un-overlapped; one short turn still rests there.
|
||||
2. Regressions, each in isolation (`uv run pytest tests/e2e/<file> -v --no-cov`): `test_stop_generation.py`, `test_no_reply_autoscroll.py`, `test_chat_persistence.py`, `test_mobile_hamburger_nav.py` (the mobile nav sits in the sticky header — the pin must not break the header/dropdown stacking at ≤640px).
|
||||
3. One `--no-gpg-sign` commit staging `.agent/ frontend/ tests/` (message per the phase overview); move `.agent/phases/todo/52_pinned_composer/` to `.agent/phases/complete/`.
|
||||
- ASSUMPTION: over-viewport overflow is produced by ~8 UI questions against the mock LLM (short deterministic answers, but each turn adds two bubbles + meta rows). If the suite shows that is not enough to exceed 720px, fall back to a saved long conversation via the phase-50 path (Save a multi-turn chat, reload with `/?chat=<id>`); no new fixture or API surface.
|
||||
@@ -20,6 +32,6 @@ One isolated Playwright story suite proving the composer never runs away from th
|
||||
- The four regression suites green in isolation (no assertion edits outside the scope the phase-48 revised contract already owns — if `test_stop_generation.py` needs a revision it must be the pinned-composer contract, nothing else).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_pinned_composer.py -v --no-cov` green in isolation (DB up).
|
||||
- [ ] `test_stop_generation.py`, `test_no_reply_autoscroll.py`, `test_chat_persistence.py`, `test_mobile_hamburger_nav.py` green in isolation.
|
||||
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||
- [x] `uv run pytest tests/e2e/test_pinned_composer.py -v --no-cov` green in isolation (4 passed, DB up).
|
||||
- [x] `test_stop_generation.py` (3), `test_no_reply_autoscroll.py` (6), `test_chat_persistence.py` (4), `test_mobile_hamburger_nav.py` (7) green in isolation — plus the layout/scroll neighbours (smoke 3, chat_rag 3, honest_deflection 3, suggestion_chips 4, loading_feedback 5, long_answers 2, markdown_tables 6, retry_answer 4, thinking_scroll 8, responsive_polish 7, share_chat 4, chat_history 5, dark_tech_theme 6, background_no_motion 8).
|
||||
- [x] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||
@@ -1,41 +0,0 @@
|
||||
# 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) is pinned to the bottom of the viewport at every scroll position — the Stop control is always reachable mid-turn without scrolling, and no new auto-scroll behaviour is introduced (the phase-42 contract stays intact).
|
||||
|
||||
## 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
|
||||
- [ ] 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).
|
||||
- [ ] On an empty/short chat the composer renders in its normal flow position (the pin does not float it over the footer or shift the layout).
|
||||
- [ ] `uv run pytest` green; coverage TOTAL >90%.
|
||||
- [ ] `uv run pytest tests/e2e/test_pinned_composer.py -v --no-cov` green in isolation (DB up).
|
||||
- [ ] Regression E2E suites green in isolation: `test_stop_generation.py`, `test_no_reply_autoscroll.py`, `test_chat_persistence.py`, `test_mobile_hamburger_nav.py`.
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
|
||||
|
||||
## Locked decisions
|
||||
- **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; 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
|
||||
```bash
|
||||
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"
|
||||
```
|
||||
Reference in New Issue
Block a user