diff --git a/apps/web/src/components/ai/chat/layouts/ChatLayout.tsx b/apps/web/src/components/ai/chat/layouts/ChatLayout.tsx index ae94a8408..72601f3af 100644 --- a/apps/web/src/components/ai/chat/layouts/ChatLayout.tsx +++ b/apps/web/src/components/ai/chat/layouts/ChatLayout.tsx @@ -103,6 +103,8 @@ export interface ChatLayoutProps { export interface ChatLayoutRef { /** Scroll messages to bottom */ scrollToBottom: () => void; + /** Scroll so a user's message is at the top of the viewport */ + scrollToUserMessage: (messageId: string) => void; } /** @@ -156,6 +158,7 @@ export const ChatLayout = React.forwardRef( // Expose methods to parent React.useImperativeHandle(ref, () => ({ scrollToBottom: () => messagesRef.current?.scrollToBottom(), + scrollToUserMessage: (messageId: string) => messagesRef.current?.scrollToUserMessage(messageId), })); // Determine position based on message state diff --git a/apps/web/src/components/ai/shared/chat/ChatMessagesArea.tsx b/apps/web/src/components/ai/shared/chat/ChatMessagesArea.tsx index 7b1137922..9383e54f8 100644 --- a/apps/web/src/components/ai/shared/chat/ChatMessagesArea.tsx +++ b/apps/web/src/components/ai/shared/chat/ChatMessagesArea.tsx @@ -1,6 +1,11 @@ /** * ChatMessagesArea - Scrollable message display area for AI chats * Used by both Agent engine and Global Assistant engine + * + * Implements "scroll-to-user-message" pattern: + * - When user sends a message, scrolls so user's message is at TOP of viewport + * - AI response streams below into the empty space + * - Doesn't auto-scroll during streaming (lets content fill viewport) */ import React, { useRef, useEffect, forwardRef, useImperativeHandle, useState, useCallback } from 'react'; @@ -11,6 +16,7 @@ import { Loader2 } from 'lucide-react'; import { MessageRenderer } from './MessageRenderer'; import { StreamingIndicator } from './StreamingIndicator'; import { UndoAiChangesDialog } from './UndoAiChangesDialog'; +import { useMessageScroll } from './useMessageScroll'; interface ChatMessagesAreaProps { /** Messages to display */ @@ -40,6 +46,12 @@ interface ChatMessagesAreaProps { export interface ChatMessagesAreaRef { /** Scroll to bottom of messages */ scrollToBottom: () => void; + /** + * Scroll so a user's message is at the top of the viewport. + * Called when user sends a message to show their message at top + * with AI response streaming below. + */ + scrollToUserMessage: (messageId: string) => void; } /** @@ -65,6 +77,18 @@ export const ChatMessagesArea = forwardRef(null); const messagesEndRef = useRef(null); const [undoDialogMessageId, setUndoDialogMessageId] = useState(null); + // Track the previous message count to detect new user messages + const prevMessageCountRef = useRef(messages.length); + + // Use the message scroll hook for "scroll-to-user-message" pattern + const { + scrollToMessage, + scrollToBottom, + } = useMessageScroll({ + scrollContainerRef: scrollAreaRef, + isStreaming, + topPadding: 16, + }); // Handler for undo from here button const handleUndoFromHere = useCallback((messageId: string) => { @@ -80,22 +104,37 @@ export const ChatMessagesArea = forwardRef { - if (scrollAreaRef.current) { - scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight; - } - }; - - // Expose scrollToBottom to parent + // Expose scroll methods to parent useImperativeHandle(ref, () => ({ scrollToBottom, + scrollToUserMessage: scrollToMessage, })); - // Auto-scroll on new messages or status change + // Track the previous lastUserMessageId to detect new user messages + const prevLastUserMessageIdRef = useRef(lastUserMessageId); + + // Auto-scroll to user message when a new user message is detected + // This implements the "scroll-to-user-message" pattern: + // When user sends a message, scroll so their message is at the top of the viewport + useEffect(() => { + // Detect new user message: lastUserMessageId changed and we have a valid ID + if ( + lastUserMessageId && + lastUserMessageId !== prevLastUserMessageIdRef.current && + !isLoading + ) { + // Use requestAnimationFrame to ensure DOM has updated + requestAnimationFrame(() => { + scrollToMessage(lastUserMessageId); + }); + } + prevLastUserMessageIdRef.current = lastUserMessageId; + }, [lastUserMessageId, isLoading, scrollToMessage]); + + // Track message count changes useEffect(() => { - scrollToBottom(); - }, [messages.length, isStreaming]); + prevMessageCountRef.current = messages.length; + }, [messages.length]); // Loading skeleton const LoadingSkeleton = () => ( diff --git a/apps/web/src/components/ai/shared/chat/CompactMessageRenderer.tsx b/apps/web/src/components/ai/shared/chat/CompactMessageRenderer.tsx index b0c8a9285..4a5fdf3bb 100644 --- a/apps/web/src/components/ai/shared/chat/CompactMessageRenderer.tsx +++ b/apps/web/src/components/ai/shared/chat/CompactMessageRenderer.tsx @@ -282,7 +282,7 @@ export const CompactMessageRenderer: React.FC = Rea if (message.messageType === 'todo_list') { if (isLoadingTasks) { return ( -
+
@@ -299,7 +299,7 @@ export const CompactMessageRenderer: React.FC = Rea if (!taskList || tasks.length === 0) { return ( -
+
No tasks found for this todo list. @@ -310,24 +310,26 @@ export const CompactMessageRenderer: React.FC = Rea } return ( - -
-
- Failed to load TODO list. Please refresh the page. +
+ +
+
+ Failed to load TODO list. Please refresh the page. +
-
- } - > - - + } + > + + +
); } @@ -338,7 +340,13 @@ export const CompactMessageRenderer: React.FC = Rea return ( <> -
+
{groupedParts.map((group, index) => { if (isTextGroupPart(group)) { const isLastTextBlock = index === groupedParts.length - 1; diff --git a/apps/web/src/components/ai/shared/chat/MessageRenderer.tsx b/apps/web/src/components/ai/shared/chat/MessageRenderer.tsx index dca6858a5..a7a6b12e1 100644 --- a/apps/web/src/components/ai/shared/chat/MessageRenderer.tsx +++ b/apps/web/src/components/ai/shared/chat/MessageRenderer.tsx @@ -283,7 +283,7 @@ export const MessageRenderer: React.FC = React.memo(({ if (message.messageType === 'todo_list') { if (isLoadingTasks) { return ( -
+
@@ -301,7 +301,7 @@ export const MessageRenderer: React.FC = React.memo(({ if (!taskList || tasks.length === 0) { return ( -
+
No tasks found for this todo list. @@ -312,24 +312,26 @@ export const MessageRenderer: React.FC = React.memo(({ } return ( - -
-
- Failed to load TODO list. Please refresh the page. +
+ +
+
+ Failed to load TODO list. Please refresh the page. +
-
- } - > - - + } + > + + +
); } @@ -338,7 +340,13 @@ export const MessageRenderer: React.FC = React.memo(({ // ============================================ return ( <> -
+
{groupedParts.map((group, index) => { if (isTextGroupPart(group)) { const isLastTextBlock = index === groupedParts.length - 1; diff --git a/apps/web/src/components/ai/shared/chat/index.ts b/apps/web/src/components/ai/shared/chat/index.ts index 386c2adf0..19c20878c 100644 --- a/apps/web/src/components/ai/shared/chat/index.ts +++ b/apps/web/src/components/ai/shared/chat/index.ts @@ -7,6 +7,7 @@ export { ChatMessagesArea, type ChatMessagesAreaRef } from './ChatMessagesArea'; export { ChatInputArea, type ChatInputAreaRef } from './ChatInputArea'; export { StreamingIndicator } from './StreamingIndicator'; export { ProviderSetupCard } from './ProviderSetupCard'; +export { useMessageScroll } from './useMessageScroll'; // Message rendering export { default as AiInput } from './AiInput'; diff --git a/apps/web/src/components/ai/shared/chat/useMessageScroll.ts b/apps/web/src/components/ai/shared/chat/useMessageScroll.ts new file mode 100644 index 000000000..0da399124 --- /dev/null +++ b/apps/web/src/components/ai/shared/chat/useMessageScroll.ts @@ -0,0 +1,162 @@ +/** + * useMessageScroll - Hook for "scroll-to-user-message" chat UX pattern + * + * This implements the common chat pattern where: + * 1. User sends a message → scrolls so user's message is at TOP of viewport + * 2. AI response streams below it into the empty space + * 3. During streaming, does NOT auto-scroll (lets content fill viewport) + * 4. User can manually scroll during streaming to follow content + * + * Designed for virtualization compatibility: + * - Uses pixel-based scrolling (not index-based) + * - Targets elements by data-message-id attribute + * - Works with react-window, react-virtualized, or similar + */ + +import { useRef, useCallback, useEffect } from 'react'; + +interface UseMessageScrollOptions { + /** Ref to the scrollable container element */ + scrollContainerRef: React.RefObject; + /** Whether the AI is currently streaming a response */ + isStreaming: boolean; + /** Padding from top of viewport when scrolling to message (default: 16px) */ + topPadding?: number; + /** Whether to use smooth scrolling (default: false for instant during send) */ + smoothScroll?: boolean; +} + +interface UseMessageScrollReturn { + /** + * Scroll so a specific message is at the top of the viewport. + * Used when user sends a message. + */ + scrollToMessage: (messageId: string) => void; + /** + * Traditional scroll to bottom of messages. + * Can be used for manual "scroll to bottom" buttons. + */ + scrollToBottom: () => void; + /** + * Whether auto-scroll is currently active. + * False during streaming if user sent a message (waiting for response). + */ + isAutoScrollActive: boolean; + /** + * Call this when user manually scrolls during streaming to re-enable following. + */ + enableAutoScroll: () => void; + /** + * Call this when user sends a message to disable auto-scroll during streaming. + */ + disableAutoScroll: () => void; +} + +export function useMessageScroll({ + scrollContainerRef, + isStreaming, + topPadding = 16, + smoothScroll = false, +}: UseMessageScrollOptions): UseMessageScrollReturn { + // Track whether we should auto-scroll during streaming + // When user sends message, we disable this so content fills viewport + const autoScrollActiveRef = useRef(true); + + // Track if we've just sent a message (to know when to scroll to it) + const pendingScrollToMessageRef = useRef(null); + + /** + * Scroll so a message element is at the top of the viewport + */ + const scrollToMessage = useCallback( + (messageId: string) => { + const container = scrollContainerRef.current; + if (!container) return; + + // Find the message element by data attribute + const messageElement = container.querySelector( + `[data-message-id="${messageId}"]` + ) as HTMLElement | null; + + if (!messageElement) { + // Message might not be rendered yet - store for later + pendingScrollToMessageRef.current = messageId; + return; + } + + // Calculate scroll position to put message at top of viewport + // We want the message's top edge at the top of the container (with padding) + const containerRect = container.getBoundingClientRect(); + const messageRect = messageElement.getBoundingClientRect(); + + // Current scroll position + distance from container top to message top + const targetScrollTop = + container.scrollTop + (messageRect.top - containerRect.top) - topPadding; + + container.scrollTo({ + top: Math.max(0, targetScrollTop), + behavior: smoothScroll ? 'smooth' : 'instant', + }); + + // Clear pending scroll since we've handled it + pendingScrollToMessageRef.current = null; + // Disable auto-scroll during streaming so response fills empty space + autoScrollActiveRef.current = false; + }, + [scrollContainerRef, topPadding, smoothScroll] + ); + + /** + * Traditional scroll to bottom + */ + const scrollToBottom = useCallback(() => { + const container = scrollContainerRef.current; + if (!container) return; + + container.scrollTo({ + top: container.scrollHeight, + behavior: smoothScroll ? 'smooth' : 'instant', + }); + }, [scrollContainerRef, smoothScroll]); + + /** + * Enable auto-scroll (e.g., user scrolled down manually during streaming) + */ + const enableAutoScroll = useCallback(() => { + autoScrollActiveRef.current = true; + }, []); + + /** + * Disable auto-scroll (e.g., user sent a message, waiting for response) + */ + const disableAutoScroll = useCallback(() => { + autoScrollActiveRef.current = false; + }, []); + + // When streaming ends, re-enable auto-scroll for next interaction + useEffect(() => { + if (!isStreaming) { + autoScrollActiveRef.current = true; + } + }, [isStreaming]); + + // Check for pending scroll when DOM updates + useEffect(() => { + if (pendingScrollToMessageRef.current) { + const messageId = pendingScrollToMessageRef.current; + // Use requestAnimationFrame to wait for DOM to settle + const frameId = requestAnimationFrame(() => { + scrollToMessage(messageId); + }); + return () => cancelAnimationFrame(frameId); + } + }); + + return { + scrollToMessage, + scrollToBottom, + isAutoScrollActive: autoScrollActiveRef.current, + enableAutoScroll, + disableAutoScroll, + }; +} diff --git a/apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx b/apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx index c7a1fd4dd..d73fbeb5d 100644 --- a/apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx +++ b/apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx @@ -289,7 +289,8 @@ const AiChatView: React.FC = ({ page }) => { ); setInput(''); inputRef.current?.clear(); - setTimeout(() => chatLayoutRef.current?.scrollToBottom(), 100); + // Note: Scrolling to the user's message is now handled automatically + // by ChatMessagesArea when it detects a new lastUserMessageId }, [ isReadOnly, input, diff --git a/apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx b/apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx index 94fe465b6..2fff5c5ad 100644 --- a/apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx +++ b/apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx @@ -479,7 +479,8 @@ const GlobalAssistantView: React.FC = () => { sendMessage({ text: input }, { body: requestBody }); setInput(''); - setTimeout(() => chatLayoutRef.current?.scrollToBottom(), 100); + // Note: Scrolling to the user's message is now handled automatically + // by ChatMessagesArea when it detects a new lastUserMessageId }; // ============================================ diff --git a/apps/web/src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx b/apps/web/src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx index 205a803c0..e150c2fe4 100644 --- a/apps/web/src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx +++ b/apps/web/src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx @@ -7,7 +7,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Loader2, Plus } from 'lucide-react'; import { ProviderModelSelector } from '@/components/ai/chat/input/ProviderModelSelector'; import { CompactMessageRenderer, AISelector, AiUsageMonitor, TasksDropdown } from '@/components/ai/shared'; -import { UndoAiChangesDialog } from '@/components/ai/shared/chat'; +import { UndoAiChangesDialog, useMessageScroll } from '@/components/ai/shared/chat'; import { useDriveStore } from '@/hooks/useDrive'; import { fetchWithAuth, patch, del } from '@/lib/auth/auth-fetch'; import { useEditingStore } from '@/stores/useEditingStore'; @@ -145,14 +145,6 @@ const SidebarChatTab: React.FC = () => { const chatInputRef = useRef(null); const prevGlobalStatusRef = useRef('ready'); - // ============================================ - // Helper Functions - // ============================================ - const scrollToBottom = useCallback(() => { - if (scrollAreaRef.current) { - scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight; - } - }, []); // ============================================ // Effects: Drive Loading @@ -323,10 +315,9 @@ const SidebarChatTab: React.FC = () => { // ============================================ // Effects: UI State // ============================================ - // Scroll to bottom when messages change (using individual deps to satisfy exhaustive-deps) - useEffect(() => { - scrollToBottom(); - }, [selectedAgent, messages.length, contextMessages.length, status, scrollToBottom]); + // Note: Auto-scroll to user messages is now handled by the useMessageScroll hook + // in the "Computed Values" section. This keeps the sidebar in sync with the + // scroll-to-user-message pattern used in the main chat interfaces. useEffect(() => { if (error) setShowError(true); @@ -386,7 +377,8 @@ const SidebarChatTab: React.FC = () => { sendMessage({ text: input }, { body }); setInput(''); - setTimeout(scrollToBottom, 100); + // Note: Scrolling to the user's message is now handled automatically + // by the useMessageScroll hook when it detects a new lastUserMessageId }, [ input, currentConversationId, @@ -399,7 +391,6 @@ const SidebarChatTab: React.FC = () => { currentProvider, currentModel, sendMessage, - scrollToBottom, ]); const handleEdit = useCallback(async (messageId: string, newContent: string) => { @@ -550,6 +541,34 @@ const SidebarChatTab: React.FC = () => { .filter(m => m.role === 'user') .slice(-1)[0]?.id; + // ============================================ + // Scroll Hook - implements "scroll-to-user-message" pattern + // ============================================ + const { + scrollToMessage, + scrollToBottom: _scrollToBottom, // Available for future "scroll to bottom" button + } = useMessageScroll({ + scrollContainerRef: scrollAreaRef, + isStreaming: displayIsStreaming, + topPadding: 12, // Smaller padding for compact sidebar + }); + + // Track previous lastUserMessageId to detect new user messages + const prevLastUserMessageIdRef = useRef(lastUserMessageId); + + // Auto-scroll to user message when a new user message is detected + useEffect(() => { + if ( + lastUserMessageId && + lastUserMessageId !== prevLastUserMessageIdRef.current + ) { + requestAnimationFrame(() => { + scrollToMessage(lastUserMessageId); + }); + } + prevLastUserMessageIdRef.current = lastUserMessageId; + }, [lastUserMessageId, scrollToMessage]); + // ============================================ // Render // ============================================