diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInterface.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInterface.tsx index 94a2c832d..ec3814a35 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInterface.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInterface.tsx @@ -7,8 +7,8 @@ import { ErrorBoundary } from "@sentry/react" import type { EditorState, LexicalEditor } from "lexical" import { AnimatePresence } from "motion/react" import { nanoid } from "nanoid" -import type { FC } from "react" -import { Suspense, useEffect, useState } from "react" +import type { FC, Ref } from "react" +import { Suspense, useEffect, useRef, useState } from "react" import { useEventCallback } from "usehooks-ts" import { useAISettingKey } from "~/atoms/settings/ai" @@ -66,9 +66,14 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => { const [scrollAreaRef, setScrollAreaRef] = useState(null) const [isAtBottom, setIsAtBottom] = useState(true) + const [messageContainerMinHeight, setMessageContainerMinHeight] = useState() + const previousMinHeightRef = useRef(0) + const messagesContentRef = useRef(null) useEffect(() => { setIsAtBottom(true) + setMessageContainerMinHeight(undefined) + previousMinHeightRef.current = 0 }, [currentChatId]) const { isLoading: isLoadingHistory } = useLoadMessages(currentChatId || "", { @@ -116,6 +121,33 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => { }, [scrollAreaRef]) const blockActions = useBlockActions() + + const scrollHeightBeforeSendingRef = useRef(0) + const scrollContainerParentRef = useRef(null) + const handleScrollPositioning = useEventCallback(() => { + const $scrollContainerParent = scrollContainerParentRef.current + if (!scrollAreaRef || !$scrollContainerParent) return + + const parentClientHeight = $scrollContainerParent.clientHeight + // Use actual content height captured before send (messages container height), not inflated by minHeight + const currentScrollHeight = scrollHeightBeforeSendingRef.current + + // Calculate new minimum height based on actual content height + // Use previousMinHeightRef which tracks the real content height, not reserved space + const baseHeight = Math.max(previousMinHeightRef.current, currentScrollHeight) + const newMinHeight = baseHeight + parentClientHeight - 250 + + setMessageContainerMinHeight(newMinHeight) + + // Scroll to the end immediately to position user message at top + nextFrame(() => { + scrollAreaRef.scrollTo({ + top: scrollAreaRef.scrollHeight, + behavior: "instant", + }) + }) + }) + const handleSendMessage = useEventCallback( (message: string | EditorState, editor: LexicalEditor | null) => { resetScrollState() @@ -161,12 +193,19 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => { }) } + // Capture actual content height (messages container), not including reserved minHeight + scrollHeightBeforeSendingRef.current = messagesContentRef.current?.scrollHeight ?? 0 chatActions.sendMessage({ parts, role: "user", id: nanoid(), }) tracker.aiChatMessageSent() + + nextFrame(() => { + // Calculate and adjust scroll positioning immediately + handleScrollPositioning() + }) }, ) @@ -174,13 +213,21 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => { if (status === "submitted") { resetScrollState() } - }, [status, resetScrollState]) + + // When AI response is complete, update the reference height but keep the container height unchanged + // This avoids CLS while ensuring next calculation is based on actual content + if (status === "ready" && scrollAreaRef && messagesContentRef.current) { + // Update the reference to actual content height for next calculation (use messages container) + previousMinHeightRef.current = messagesContentRef.current.scrollHeight + // Keep the current minHeight unchanged to avoid CLS + } + }, [status, resetScrollState, messageContainerMinHeight, scrollAreaRef]) const shouldShowScrollToBottom = hasMessages && !isAtBottom && !isLoadingHistory return ( -
+
{!hasMessages && !isLoadingHistory ? ( @@ -197,8 +244,15 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => {
) : ( -
- +
+ {(status === "submitted" || status === "streaming") && }
@@ -248,11 +302,11 @@ export const ChatInterface = (props: ChatInterfaceProps) => ( ) -const Messages: FC = () => { +const Messages: FC<{ contentRef?: Ref }> = ({ contentRef }) => { const messages = useMessages() return ( -
+
{messages.map((message, index) => { const isLastMessage = index === messages.length - 1 return (