diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 951eb17da98..cc6055e8a11 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -89,6 +89,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 @@ -270,6 +278,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 diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx index b3d9cb78edd..f5d948485cf 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx @@ -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(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 ( -
+
{/* 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. */}