fix(mothership): keep chat pinned to bottom across multi-line input resizes (#5711)

This commit is contained in:
Theodore Li
2026-07-16 14:41:47 -04:00
committed by GitHub
parent f03b4337fa
commit b5196a41a8
2 changed files with 53 additions and 4 deletions
@@ -93,6 +93,14 @@ const ROW_HEIGHT_ESTIMATE = {
*/
const OVERSCAN = 6
/**
* How close to the bottom (px) the transcript must be to count as pinned for
* re-pinning across container resizes. Covers the fractional sub-pixel gap a
* DPR-scaled `scrollTop` can leave, without capturing a user who deliberately
* scrolled up.
*/
const PIN_THRESHOLD = 2
/**
* Initial-scroll sentinel. Distinct from every real `chatId` value — including
* `undefined` (a not-yet-persisted chat) — so the first scroll-to-bottom fires
@@ -282,6 +290,32 @@ export function MothershipChat({
const hasMessages = messages.length > 0
/**
* Keep a bottom-pinned transcript pinned when the scroll container resizes.
* Growing or shrinking the multi-line input (or resizing the panel/window)
* changes the container height while `scrollTop` stays put, which silently
* unpins the chat from the bottom — the last message slides behind the
* input. Pinned-ness is sampled on every scroll (before the resize lands),
* so a user who scrolled up is never yanked back down.
*/
useEffect(() => {
const el = scrollElementRef.current
if (!el) return
let wasAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= PIN_THRESHOLD
const onScroll = () => {
wasAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= PIN_THRESHOLD
}
const observer = new ResizeObserver(() => {
if (wasAtBottom) el.scrollTop = el.scrollHeight - el.clientHeight
})
el.addEventListener('scroll', onScroll, { passive: true })
observer.observe(el)
return () => {
el.removeEventListener('scroll', onScroll)
observer.disconnect()
}
}, [])
/**
* Stable per-row identity for virtualizer measurement caching and React
* reconciliation. User rows key on their message id; assistant rows key on
@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useLayoutEffect, useMemo } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import { cn } from '@sim/emcn'
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
import {
@@ -71,14 +71,25 @@ export function PromptEditor({
onArrowUpOnEmpty,
}: PromptEditorProps) {
const { textareaRef, value } = editor
const scrollerRef = useRef<HTMLDivElement>(null)
/**
* Autosize: grow the textarea to its full content height; the scroller caps
* the visible height and scrolls textarea + overlay together natively. The
* scroller's box is locked while the textarea collapses to `auto` for
* measurement — the scrollHeight read forces a layout at the collapsed
* height, and without the lock that transient layout grows the chat scroll
* container, letting the browser clamp a bottom-pinned transcript upward by
* the input's grown height on every multi-line edit.
*/
useLayoutEffect(() => {
const textarea = textareaRef.current
if (!textarea) return
// Grow the textarea to its full content height; the scroller caps the
// visible height and scrolls textarea + overlay together natively.
const scroller = scrollerRef.current
if (scroller) scroller.style.height = `${scroller.offsetHeight}px`
textarea.style.height = 'auto'
textarea.style.height = `${textarea.scrollHeight}px`
if (scroller) scroller.style.height = ''
}, [value, textareaRef])
useEffect(() => {
@@ -171,7 +182,11 @@ export function PromptEditor({
}, [value, editor.contexts])
return (
<div className={cn(SCROLLER_CLASSES, 'cursor-text', className)} onClick={handleSurfaceClick}>
<div
ref={scrollerRef}
className={cn(SCROLLER_CLASSES, 'cursor-text', className)}
onClick={handleSurfaceClick}
>
{/* Sizer for textarea + overlay: the textarea grows to full content
height and the overlay fills it via `inset-0`, so both are flow
children of the same scroller and co-scroll natively. */}