From 78830f6e9d7fb5ab8e0919f60a4f5819d1a29df5 Mon Sep 17 00:00:00 2001 From: Eva Ho Date: Tue, 11 Nov 2025 11:56:50 -0500 Subject: [PATCH] wip --- app/ui/app/package-lock.json | 12 +- app/ui/app/package.json | 3 +- app/ui/app/src/components/Chat.tsx | 113 +++--- app/ui/app/src/components/MessageList.tsx | 16 +- .../components/ai-elements/conversation.tsx | 111 ++++++ .../app/src/components/ai-elements/index.ts | 10 + app/ui/app/src/hooks/useMessageAutoscroll.ts | 347 ------------------ app/ui/app/src/lib/utils.ts | 5 + 8 files changed, 205 insertions(+), 412 deletions(-) create mode 100644 app/ui/app/src/components/ai-elements/conversation.tsx create mode 100644 app/ui/app/src/components/ai-elements/index.ts delete mode 100644 app/ui/app/src/hooks/useMessageAutoscroll.ts create mode 100644 app/ui/app/src/lib/utils.ts diff --git a/app/ui/app/package-lock.json b/app/ui/app/package-lock.json index 0dcd91a20..76c03fc5c 100644 --- a/app/ui/app/package-lock.json +++ b/app/ui/app/package-lock.json @@ -27,7 +27,8 @@ "remark-math": "^6.0.0", "streamdown": "^1.4.0", "unist-builder": "^4.0.0", - "unist-util-parents": "^3.0.0" + "unist-util-parents": "^3.0.0", + "use-stick-to-bottom": "^1.1.1" }, "devDependencies": { "@chromatic-com/storybook": "^4.0.1", @@ -12739,6 +12740,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-stick-to-bottom": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/use-stick-to-bottom/-/use-stick-to-bottom-1.1.1.tgz", + "integrity": "sha512-JkDp0b0tSmv7HQOOpL1hT7t7QaoUBXkq045WWWOFDTlLGRzgIIyW7vyzOIJzY7L2XVIG7j1yUxeDj2LHm9Vwng==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/use-sync-external-store": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", diff --git a/app/ui/app/package.json b/app/ui/app/package.json index 5532e70f0..d3922c784 100644 --- a/app/ui/app/package.json +++ b/app/ui/app/package.json @@ -36,7 +36,8 @@ "remark-math": "^6.0.0", "streamdown": "^1.4.0", "unist-builder": "^4.0.0", - "unist-util-parents": "^3.0.0" + "unist-util-parents": "^3.0.0", + "use-stick-to-bottom": "^1.1.1" }, "devDependencies": { "@chromatic-com/storybook": "^4.0.1", diff --git a/app/ui/app/src/components/Chat.tsx b/app/ui/app/src/components/Chat.tsx index e0fcb4ff3..85b51f55b 100644 --- a/app/ui/app/src/components/Chat.tsx +++ b/app/ui/app/src/components/Chat.tsx @@ -4,6 +4,11 @@ import { FileUpload } from "./FileUpload"; import { DisplayUpgrade } from "./DisplayUpgrade"; import { DisplayStale } from "./DisplayStale"; import { DisplayLogin } from "./DisplayLogin"; +import { + Conversation, + ConversationContent, + ConversationScrollButton, +} from "./ai-elements"; import { useChat, useSendMessage, @@ -15,14 +20,7 @@ import { useDismissStaleModel, } from "@/hooks/useChats"; import { useHealth } from "@/hooks/useHealth"; -import { useMessageAutoscroll } from "@/hooks/useMessageAutoscroll"; -import { - useState, - useEffect, - useLayoutEffect, - useRef, - useCallback, -} from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { useSelectedModel } from "@/hooks/useSelectedModel"; @@ -47,7 +45,6 @@ export default function Chat({ chatId }: { chatId: string }) { index: number; originalMessage: Message; } | null>(null); - const prevChatIdRef = useRef(chatId); const chatFormCallbackRef = useRef< | (( @@ -102,27 +99,40 @@ export default function Chat({ chatId }: { chatId: string }) { const sendMessageMutation = useSendMessage(chatId); - const { containerRef, handleNewUserMessage, spacerHeight } = - useMessageAutoscroll({ - messages, - isStreaming, - chatId, - }); + const latestMessageRef = useRef(null); + const prevMessageCountRef = useRef(messages.length); - // Scroll to bottom only when switching to a different existing chat - useLayoutEffect(() => { - // Only scroll if the chatId actually changed (not just messages updating) - if ( - prevChatIdRef.current !== chatId && - containerRef.current && - messages.length > 0 && - chatId !== "new" - ) { - // Always scroll to the bottom when opening a chat - containerRef.current.scrollTop = containerRef.current.scrollHeight; + // Scroll to latest message when messages change + useEffect(() => { + // Only scroll if a new message was actually added (not just re-render) + if (messages.length > prevMessageCountRef.current) { + if (latestMessageRef.current) { + // Find the scrollable parent container + let scrollContainer = latestMessageRef.current.parentElement; + while (scrollContainer) { + const overflowY = window.getComputedStyle(scrollContainer).overflowY; + if (overflowY === "auto" || overflowY === "scroll") { + break; + } + scrollContainer = scrollContainer.parentElement; + } + + if (scrollContainer) { + const containerRect = scrollContainer.getBoundingClientRect(); + const targetRect = latestMessageRef.current.getBoundingClientRect(); + const scrollAmount = + targetRect.top - containerRect.top + scrollContainer.scrollTop; + + scrollContainer.scrollTo({ + top: scrollAmount, + behavior: "smooth", + }); + } + } } - prevChatIdRef.current = chatId; - }, [chatId, messages.length]); + + prevMessageCountRef.current = messages.length; + }, [messages.length, latestMessageRef]); // Simplified submit handler - ChatForm handles all the attachment logic const handleChatFormSubmit = ( @@ -168,7 +178,6 @@ export default function Chat({ chatId }: { chatId: string }) { // Clear edit mode after submission setEditingMessage(null); - handleNewUserMessage(); }; const handleEditMessage = (content: string, index: number) => { @@ -193,8 +202,6 @@ export default function Chat({ chatId }: { chatId: string }) { ); }; - const isWindows = navigator.platform.toLowerCase().includes("win"); - return chatId === "new" || chatQuery ? ( ) : (
-
- { - handleEditMessage(content, index); - }} - editingMessageIndex={editingMessage?.index} - error={chatError} - browserToolResult={browserToolResult} - /> -
+ + { + handleEditMessage(content, index); + }} + editingMessageIndex={editingMessage?.index} + error={chatError} + browserToolResult={browserToolResult} + latestMessageRef={latestMessageRef} + /> + + +
{selectedModel && shouldShowStaleDisplay && ( @@ -248,14 +257,6 @@ export default function Chat({ chatId }: { chatId: string }) { dismissStaleModel(selectedModel?.model || "") } chatId={chatId} - onScrollToBottom={() => { - if (containerRef.current) { - containerRef.current.scrollTo({ - top: containerRef.current.scrollHeight, - behavior: "smooth", - }); - } - }} />
)} diff --git a/app/ui/app/src/components/MessageList.tsx b/app/ui/app/src/components/MessageList.tsx index 869ab40ca..5953634a6 100644 --- a/app/ui/app/src/components/MessageList.tsx +++ b/app/ui/app/src/components/MessageList.tsx @@ -6,7 +6,6 @@ import { ErrorMessage } from "./ErrorMessage"; export default function MessageList({ messages, - spacerHeight, isWaitingForLoad, isStreaming, downloadProgress, @@ -14,9 +13,9 @@ export default function MessageList({ editingMessageIndex, error, browserToolResult, + latestMessageRef, }: { messages: MessageType[]; - spacerHeight: number; isWaitingForLoad?: boolean; isStreaming: boolean; downloadProgress?: DownloadEvent; @@ -24,6 +23,7 @@ export default function MessageList({ editingMessageIndex?: number; error?: ErrorEvent | null; browserToolResult?: any; + latestMessageRef?: React.RefObject; }) { const [showDots, setShowDots] = React.useState(false); const isDownloadingModel = downloadProgress && !downloadProgress.done; @@ -84,13 +84,18 @@ export default function MessageList({ return (
{messages.map((message, idx) => { const lastToolQuery = lastToolQueries[idx]; + const isLastMessage = idx === messages.length - 1; return ( -
+
)} - - {/* Dynamic spacer to allow scrolling the last message to the top of the container */} - ); } diff --git a/app/ui/app/src/components/ai-elements/conversation.tsx b/app/ui/app/src/components/ai-elements/conversation.tsx new file mode 100644 index 000000000..3f52aecdd --- /dev/null +++ b/app/ui/app/src/components/ai-elements/conversation.tsx @@ -0,0 +1,111 @@ +"use client"; + +import clsx from "clsx"; +import type { ComponentProps } from "react"; +import { useCallback } from "react"; +import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; + +export type ConversationProps = ComponentProps; + +export const Conversation = ({ className, ...props }: ConversationProps) => ( + +); + +export type ConversationContentProps = ComponentProps< + typeof StickToBottom.Content +>; + +export const ConversationContent = ({ + className, + ...props +}: ConversationContentProps) => ( + +); + +export type ConversationEmptyStateProps = ComponentProps<"div"> & { + title?: string; + description?: string; + icon?: React.ReactNode; +}; + +export const ConversationEmptyState = ({ + className, + title = "No messages yet", + description = "Start a conversation to see messages here", + icon, + children, + ...props +}: ConversationEmptyStateProps) => ( +
+ {children ?? ( + <> + {icon &&
{icon}
} +
+

{title}

+ {description && ( +

{description}

+ )} +
+ + )} +
+); + +export type ConversationScrollButtonProps = ComponentProps<"button">; + +export const ConversationScrollButton = ({ + className, + ...props +}: ConversationScrollButtonProps) => { + const { isAtBottom, scrollToBottom } = useStickToBottomContext(); + + const handleScrollToBottom = useCallback(() => { + scrollToBottom(); + }, [scrollToBottom]); + + return ( + !isAtBottom && ( + + ) + ); +}; diff --git a/app/ui/app/src/components/ai-elements/index.ts b/app/ui/app/src/components/ai-elements/index.ts new file mode 100644 index 000000000..f55210518 --- /dev/null +++ b/app/ui/app/src/components/ai-elements/index.ts @@ -0,0 +1,10 @@ +export { + Conversation, + ConversationContent, + ConversationEmptyState, + ConversationScrollButton, + type ConversationProps, + type ConversationContentProps, + type ConversationEmptyStateProps, + type ConversationScrollButtonProps, +} from "./conversation"; diff --git a/app/ui/app/src/hooks/useMessageAutoscroll.ts b/app/ui/app/src/hooks/useMessageAutoscroll.ts deleted file mode 100644 index 0c980766e..000000000 --- a/app/ui/app/src/hooks/useMessageAutoscroll.ts +++ /dev/null @@ -1,347 +0,0 @@ -import { - useRef, - useCallback, - useEffect, - useLayoutEffect, - useState, - useMemo, -} from "react"; -import type { Message } from "@/gotypes"; - -// warning: this file is all claude code, needs to be looked into more closely - -interface UseMessageAutoscrollOptions { - messages: Message[]; - isStreaming: boolean; - chatId: string; -} - -interface MessageAutoscrollBehavior { - handleNewUserMessage: () => void; - containerRef: React.RefObject; - spacerHeight: number; -} - -export const useMessageAutoscroll = ({ - messages, - isStreaming, - chatId, -}: UseMessageAutoscrollOptions): MessageAutoscrollBehavior => { - const containerRef = useRef(null); - const pendingScrollToUserMessage = useRef(false); - const [spacerHeight, setSpacerHeight] = useState(0); - const lastScrollHeightRef = useRef(0); - const lastScrollTopRef = useRef(0); - const [isActiveInteraction, setIsActiveInteraction] = useState(false); - const [hasSubmittedMessage, setHasSubmittedMessage] = useState(false); - const prevChatIdRef = useRef(chatId); - - // Find the last user message index from React state - const getLastUserMessageIndex = useCallback(() => { - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === "user") { - return i; - } - } - return -1; - }, [messages]); - - const scrollToMessage = useCallback((messageIndex: number) => { - if (!containerRef.current || messageIndex < 0) { - return; - } - - const container = containerRef.current; - // select the exact element by its data-message-index to avoid index mismatches - const targetElement = container.querySelector( - `[data-message-index="${messageIndex}"]`, - ) as HTMLElement | null; - - if (!targetElement) return; - - const containerHeight = container.clientHeight; - const containerStyle = window.getComputedStyle(container); - const paddingTop = parseFloat(containerStyle.paddingTop) || 0; - const scrollHeight = container.scrollHeight; - const messageHeight = targetElement.offsetHeight; - - // Check if the message is large, which is 70% of the container height - const isLarge = messageHeight > containerHeight * 0.7; - - let targetPosition: number = targetElement.offsetTop - paddingTop; // default to scrolling the message to the top of the window - - if (isLarge) { - // when the message is large scroll to the bottom of it - targetPosition = scrollHeight - containerHeight; - } - - // Ensure we don't scroll past content boundaries - const maxScroll = scrollHeight - containerHeight; - const finalPosition = Math.min(Math.max(0, targetPosition), maxScroll); - - container.scrollTo({ - top: finalPosition, - behavior: "smooth", - }); - }, []); - - // Calculate and set the spacer height based on container dimensions - const updateSpacerHeight = useCallback(() => { - if (!containerRef.current) { - return; - } - - const containerHeight = containerRef.current.clientHeight; - - // Find the last user message to calculate spacer for - const lastUserIndex = getLastUserMessageIndex(); - - if (lastUserIndex < 0) { - setSpacerHeight(0); - return; - } - - const messageElements = containerRef.current.querySelectorAll( - "[data-message-index]", - ) as NodeListOf; - - if (!messageElements || messageElements.length === 0) { - setSpacerHeight(0); - return; - } - - const targetElement = containerRef.current.querySelector( - `[data-message-index="${lastUserIndex}"]`, - ) as HTMLElement | null; - - if (!targetElement) { - setSpacerHeight(0); - return; - } - - const elementsAfter = Array.from(messageElements).filter((el) => { - const idx = Number(el.dataset.messageIndex); - return Number.isFinite(idx) && idx > lastUserIndex; - }); - - const contentHeightAfterTarget = elementsAfter.reduce( - (sum, el) => sum + el.offsetHeight, - 0, - ); - - // Calculate the spacer height needed to position the user message at the top - // Add extra space for assistant response area - const targetMessageHeight = targetElement.offsetHeight; - - // Calculate spacer to position the last user message at the top - // For new messages, we want them to appear at the top regardless of content after - // For large messages, we want to preserve the scroll-to-bottom behavior - // which shows part of the message and space for streaming response - let baseHeight: number; - - if (contentHeightAfterTarget === 0) { - // No content after the user message (new message case) - // Position it at the top with some padding - baseHeight = Math.max(0, containerHeight - targetMessageHeight); - } else { - // Content exists after the user message - // Calculate spacer to position user message at top - baseHeight = Math.max( - 0, - containerHeight - contentHeightAfterTarget - targetMessageHeight, - ); - } - - // Only apply spacer height when actively interacting (streaming or pending new message) - // When just viewing a chat, don't add extra space - if (!isActiveInteraction) { - setSpacerHeight(0); - return; - } - - // Add extra space for assistant response only when streaming - const extraSpaceForAssistant = isStreaming ? containerHeight * 0.4 : 0; - const calculatedHeight = baseHeight + extraSpaceForAssistant; - - setSpacerHeight(calculatedHeight); - }, [getLastUserMessageIndex, isStreaming, isActiveInteraction]); - - // Handle new user message submission - const handleNewUserMessage = useCallback(() => { - // Mark that we're expecting a new message and should scroll to it - pendingScrollToUserMessage.current = true; - setIsActiveInteraction(true); - setHasSubmittedMessage(true); - }, []); - - // Use layoutEffect to scroll immediately after DOM updates - useLayoutEffect(() => { - if (pendingScrollToUserMessage.current) { - // Find the last user message from current state - const targetUserIndex = getLastUserMessageIndex(); - - if (targetUserIndex >= 0) { - requestAnimationFrame(() => { - updateSpacerHeight(); - requestAnimationFrame(() => { - scrollToMessage(targetUserIndex); - pendingScrollToUserMessage.current = false; - }); - }); - } else { - pendingScrollToUserMessage.current = false; - // Reset active interaction if no target found - setIsActiveInteraction(isStreaming); - } - } - }, [ - messages, - getLastUserMessageIndex, - scrollToMessage, - updateSpacerHeight, - isStreaming, - ]); - - // Update active interaction state based on streaming and message submission - useEffect(() => { - if ( - isStreaming || - pendingScrollToUserMessage.current || - hasSubmittedMessage - ) { - setIsActiveInteraction(true); - } else { - setIsActiveInteraction(false); - } - }, [isStreaming, hasSubmittedMessage]); - - useEffect(() => { - if (prevChatIdRef.current !== chatId) { - setIsActiveInteraction(false); - setHasSubmittedMessage(false); - prevChatIdRef.current = chatId; - } - }, [chatId]); - - // Recalculate spacer height when messages change - useEffect(() => { - updateSpacerHeight(); - }, [messages, updateSpacerHeight]); - - // Use ResizeObserver to handle dynamic content changes - useEffect(() => { - if (!containerRef.current) return; - - let resizeTimeout: ReturnType; - let immediateUpdate = false; - - const resizeObserver = new ResizeObserver((entries) => { - // Check if this is a significant height change (like collapsing content) - let hasSignificantChange = false; - for (const entry of entries) { - const element = entry.target as HTMLElement; - if ( - element.dataset.messageIndex && - entry.contentRect.height !== element.offsetHeight - ) { - const heightDiff = Math.abs( - entry.contentRect.height - element.offsetHeight, - ); - if (heightDiff > 50) { - hasSignificantChange = true; - break; - } - } - } - - // For significant changes, update immediately - if (hasSignificantChange || immediateUpdate) { - updateSpacerHeight(); - immediateUpdate = false; - } else { - // For small changes (like streaming text), debounce - clearTimeout(resizeTimeout); - resizeTimeout = setTimeout(() => { - updateSpacerHeight(); - }, 100); - } - }); - - // Also use MutationObserver for immediate attribute changes - const mutationObserver = new MutationObserver((mutations) => { - // Check if any mutations are related to expanding/collapsing - const hasToggle = mutations.some( - (mutation) => - mutation.type === "attributes" && - (mutation.attributeName === "class" || - mutation.attributeName === "style" || - mutation.attributeName === "open" || - mutation.attributeName === "data-expanded"), - ); - - if (hasToggle) { - immediateUpdate = true; - updateSpacerHeight(); - } - }); - - // Observe the container and all messages - resizeObserver.observe(containerRef.current); - mutationObserver.observe(containerRef.current, { - attributes: true, - subtree: true, - attributeFilter: ["class", "style", "open", "data-expanded"], - }); - - // Observe all message elements for size changes - const messageElements = containerRef.current.querySelectorAll( - "[data-message-index]", - ); - messageElements.forEach((element) => { - resizeObserver.observe(element); - }); - - return () => { - clearTimeout(resizeTimeout); - resizeObserver.disconnect(); - mutationObserver.disconnect(); - }; - }, [messages, updateSpacerHeight]); - - // Track scroll position - useEffect(() => { - if (!containerRef.current) return; - - const container = containerRef.current; - const handleScroll = () => { - lastScrollTopRef.current = container.scrollTop; - lastScrollHeightRef.current = container.scrollHeight; - }; - - container.addEventListener("scroll", handleScroll); - - // Initialize scroll tracking - lastScrollTopRef.current = container.scrollTop; - lastScrollHeightRef.current = container.scrollHeight; - - return () => { - container.removeEventListener("scroll", handleScroll); - }; - }, []); - - // Cleanup on unmount - useEffect(() => { - return () => { - pendingScrollToUserMessage.current = false; - }; - }, []); - - return useMemo( - () => ({ - handleNewUserMessage, - containerRef, - spacerHeight, - }), - [handleNewUserMessage, containerRef, spacerHeight], - ); -}; diff --git a/app/ui/app/src/lib/utils.ts b/app/ui/app/src/lib/utils.ts new file mode 100644 index 000000000..0e80065fa --- /dev/null +++ b/app/ui/app/src/lib/utils.ts @@ -0,0 +1,5 @@ +import { clsx, type ClassValue } from "clsx"; + +export function cn(...inputs: ClassValue[]) { + return clsx(inputs); +}