Skip to content

Commit b5196a4

Browse files
fix(mothership): keep chat pinned to bottom across multi-line input resizes (#5711)
1 parent f03b433 commit b5196a4

2 files changed

Lines changed: 53 additions & 4 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,14 @@ const ROW_HEIGHT_ESTIMATE = {
9393
*/
9494
const OVERSCAN = 6
9595

96+
/**
97+
* How close to the bottom (px) the transcript must be to count as pinned for
98+
* re-pinning across container resizes. Covers the fractional sub-pixel gap a
99+
* DPR-scaled `scrollTop` can leave, without capturing a user who deliberately
100+
* scrolled up.
101+
*/
102+
const PIN_THRESHOLD = 2
103+
96104
/**
97105
* Initial-scroll sentinel. Distinct from every real `chatId` value — including
98106
* `undefined` (a not-yet-persisted chat) — so the first scroll-to-bottom fires
@@ -282,6 +290,32 @@ export function MothershipChat({
282290

283291
const hasMessages = messages.length > 0
284292

293+
/**
294+
* Keep a bottom-pinned transcript pinned when the scroll container resizes.
295+
* Growing or shrinking the multi-line input (or resizing the panel/window)
296+
* changes the container height while `scrollTop` stays put, which silently
297+
* unpins the chat from the bottom — the last message slides behind the
298+
* input. Pinned-ness is sampled on every scroll (before the resize lands),
299+
* so a user who scrolled up is never yanked back down.
300+
*/
301+
useEffect(() => {
302+
const el = scrollElementRef.current
303+
if (!el) return
304+
let wasAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= PIN_THRESHOLD
305+
const onScroll = () => {
306+
wasAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= PIN_THRESHOLD
307+
}
308+
const observer = new ResizeObserver(() => {
309+
if (wasAtBottom) el.scrollTop = el.scrollHeight - el.clientHeight
310+
})
311+
el.addEventListener('scroll', onScroll, { passive: true })
312+
observer.observe(el)
313+
return () => {
314+
el.removeEventListener('scroll', onScroll)
315+
observer.disconnect()
316+
}
317+
}, [])
318+
285319
/**
286320
* Stable per-row identity for virtualizer measurement caching and React
287321
* reconciliation. User rows key on their message id; assistant rows key on

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useCallback, useEffect, useLayoutEffect, useMemo } from 'react'
3+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
44
import { cn } from '@sim/emcn'
55
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
66
import {
@@ -71,14 +71,25 @@ export function PromptEditor({
7171
onArrowUpOnEmpty,
7272
}: PromptEditorProps) {
7373
const { textareaRef, value } = editor
74+
const scrollerRef = useRef<HTMLDivElement>(null)
7475

76+
/**
77+
* Autosize: grow the textarea to its full content height; the scroller caps
78+
* the visible height and scrolls textarea + overlay together natively. The
79+
* scroller's box is locked while the textarea collapses to `auto` for
80+
* measurement — the scrollHeight read forces a layout at the collapsed
81+
* height, and without the lock that transient layout grows the chat scroll
82+
* container, letting the browser clamp a bottom-pinned transcript upward by
83+
* the input's grown height on every multi-line edit.
84+
*/
7585
useLayoutEffect(() => {
7686
const textarea = textareaRef.current
7787
if (!textarea) return
78-
// Grow the textarea to its full content height; the scroller caps the
79-
// visible height and scrolls textarea + overlay together natively.
88+
const scroller = scrollerRef.current
89+
if (scroller) scroller.style.height = `${scroller.offsetHeight}px`
8090
textarea.style.height = 'auto'
8191
textarea.style.height = `${textarea.scrollHeight}px`
92+
if (scroller) scroller.style.height = ''
8293
}, [value, textareaRef])
8394

8495
useEffect(() => {
@@ -171,7 +182,11 @@ export function PromptEditor({
171182
}, [value, editor.contexts])
172183

173184
return (
174-
<div className={cn(SCROLLER_CLASSES, 'cursor-text', className)} onClick={handleSurfaceClick}>
185+
<div
186+
ref={scrollerRef}
187+
className={cn(SCROLLER_CLASSES, 'cursor-text', className)}
188+
onClick={handleSurfaceClick}
189+
>
175190
{/* Sizer for textarea + overlay: the textarea grows to full content
176191
height and the overlay fills it via `inset-0`, so both are flow
177192
children of the same scroller and co-scroll natively. */}

0 commit comments

Comments
 (0)