Skip to content

Commit 56574c8

Browse files
fix(mothership): keep chat pinned to bottom across multi-line input resizes
1 parent bfe8386 commit 56574c8

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
@@ -89,6 +89,14 @@ const ROW_HEIGHT_ESTIMATE = {
8989
*/
9090
const OVERSCAN = 6
9191

92+
/**
93+
* How close to the bottom (px) the transcript must be to count as pinned for
94+
* re-pinning across container resizes. Covers the fractional sub-pixel gap a
95+
* DPR-scaled `scrollTop` can leave, without capturing a user who deliberately
96+
* scrolled up.
97+
*/
98+
const PIN_THRESHOLD = 2
99+
92100
/**
93101
* Initial-scroll sentinel. Distinct from every real `chatId` value — including
94102
* `undefined` (a not-yet-persisted chat) — so the first scroll-to-bottom fires
@@ -270,6 +278,32 @@ export function MothershipChat({
270278

271279
const hasMessages = messages.length > 0
272280

281+
/**
282+
* Keep a bottom-pinned transcript pinned when the scroll container resizes.
283+
* Growing or shrinking the multi-line input (or resizing the panel/window)
284+
* changes the container height while `scrollTop` stays put, which silently
285+
* unpins the chat from the bottom — the last message slides behind the
286+
* input. Pinned-ness is sampled on every scroll (before the resize lands),
287+
* so a user who scrolled up is never yanked back down.
288+
*/
289+
useEffect(() => {
290+
const el = scrollElementRef.current
291+
if (!el) return
292+
let wasAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= PIN_THRESHOLD
293+
const onScroll = () => {
294+
wasAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= PIN_THRESHOLD
295+
}
296+
const observer = new ResizeObserver(() => {
297+
if (wasAtBottom) el.scrollTop = el.scrollHeight - el.clientHeight
298+
})
299+
el.addEventListener('scroll', onScroll, { passive: true })
300+
observer.observe(el)
301+
return () => {
302+
el.removeEventListener('scroll', onScroll)
303+
observer.disconnect()
304+
}
305+
}, [])
306+
273307
/**
274308
* Stable per-row identity for virtualizer measurement caching and React
275309
* 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)