web: message box scrolls at max height + no feed jump as it grows (#201)
The composer textarea had `overflow: hidden`, so once autogrow capped it at max-height the earlier lines became unreachable (no wheel/drag scroll). Switch to `overflow-y: auto` (overflow-x stays hidden — text wraps) so a long draft scrolls its own overflow. As the box grows it shrinks the flex feed; the existing ResizeObserver re-pins to the bottom, but only on a later frame, so WebKit paints one frame stranded a line short of the bottom — the visible "jump" in the Mac app (WKWebView). Re-pin synchronously in the input handler, in the same layout pass, guarded by feedAtBottom so a reader who scrolled up isn't yanked down. Verified in Playwright's WebKit engine (same as WKWebView): the box becomes wheel-scrollable at max height, and typing produces zero painted frames off the bottom.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
3 files changed,
+12
−1
changelog/2026-07-05-composer-scroll.md+1 −0web/static/apps/chat.js+6 −0web/static/style.css+5 −1
diff --git a/changelog/2026-07-05-composer-scroll.md b/changelog/2026-07-05-composer-scroll.md| new file mode 100644 |
| index 0000000..855abf8 |
| --- /dev/null |
| +++ b/changelog/2026-07-05-composer-scroll.md |
| @@ -0,0 +1 @@ |
| +The message box now scrolls once a long draft grows past its maximum height, instead of hiding the earlier lines — and typing a multi-line message no longer nudges the conversation up a notch as the box grows. |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index 5721a6b..f0214a9 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -2610,6 +2610,12 @@ function buildSkeleton(el, ctx) { |
| input.addEventListener("input", () => { |
| input.style.height = "auto"; |
| input.style.height = Math.min(input.scrollHeight, 140) + "px"; |
| + // The composer just grew, shrinking the flex feed. Re-pin to the newest |
| + // message *here*, synchronously in the same layout — the ResizeObserver below |
| + // also re-pins, but only on a later frame, which leaves one painted frame |
| + // stranded a line short of the bottom (the visible "jump" on WebKit / the Mac |
| + // app). Guard on feedAtBottom so typing never yanks a reader who scrolled up. |
| + if (feedAtBottom) feed.scrollTop = feed.scrollHeight; |
| refreshSend(); |
| updateTypeahead(); |
| // A "/command" in progress is private — never announce typing for it (and |
diff --git a/web/static/style.css b/web/static/style.css| index 0e73d69..eb04291 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -1182,7 +1182,11 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| min-height: 40px; |
| border-radius: 20px; |
| font-family: inherit; |
| - overflow: hidden; |
| + /* Autogrow caps at max-height (see the JS); past that the field must scroll its |
| + own overflow rather than swallow the earlier lines — so overflow-y is auto, |
| + not hidden. overflow-x stays hidden since the text wraps. */ |
| + overflow-x: hidden; |
| + overflow-y: auto; |
| /* Light: lift the field a touch above the panel. Dark: no fill — mixing the |
| dark panel toward white read as a bright grey slab, so blend into the band. */ |
| background: light-dark(color-mix(in srgb, var(--panel) 50%, white 50%), transparent); |