Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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. */}
Expand Down
Loading