From 650d4f8e45bf6149083a0818d4f26675e10b98ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 15 May 2026 19:07:42 +0200 Subject: [PATCH] feat(coach): re-pin the message-thread scroll when the soft keyboard opens Subscribe to window.visualViewport.resize so the last bubble stays visible when the phone's soft keyboard slides in. Without the listener the scroller's scrollHeight reflects the layout viewport rather than the visible region, leaving the tail of the thread hidden behind the keyboard. The handler only re-pins when the user was already at the bottom, matching the existing auto-scroll contract. --- .../insights/coach-panel/message-thread.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 0e2d9357d..87cb1150a 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -198,6 +198,28 @@ export function MessageThread({ } }, [messages.length, streaming?.content, optimisticUser?.localId]); + // v1.4.27 R3d MB4 / CF-74 — re-pin to the bottom when the + // visual viewport shrinks (typically the soft keyboard opening on + // a phone). Without this the last bubble drifts behind the keyboard + // because the scroller's `scrollHeight` references the layout + // viewport, not the visible region. Listening on + // `window.visualViewport.resize` and re-issuing the scroll keeps the + // tail of the thread visible as the keyboard slides in and out. + useEffect(() => { + if (typeof window === "undefined") return; + const vv = window.visualViewport; + if (!vv) return; + const handleResize = () => { + const el = scrollerRef.current; + if (!el) return; + if (wasPinnedRef.current) { + el.scrollTo({ top: el.scrollHeight, behavior: "auto" }); + } + }; + vv.addEventListener("resize", handleResize); + return () => vv.removeEventListener("resize", handleResize); + }, []); + if (messages.length === 0 && !streamingActive && !optimisticActive) { return (