mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(mothership): keep chat pinned to bottom across multi-line input resizes (#5711)
This commit is contained in:
+34
@@ -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
|
||||
|
||||
+19
-4
@@ -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. */}
|
||||
|
||||
Reference in New Issue
Block a user