Skip to content
Open
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
3 changes: 3 additions & 0 deletions apps/web/src/components/ai/chat/layouts/ChatLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -156,6 +158,7 @@ export const ChatLayout = React.forwardRef<ChatLayoutRef, ChatLayoutProps>(
// Expose methods to parent
React.useImperativeHandle(ref, () => ({
scrollToBottom: () => messagesRef.current?.scrollToBottom(),
scrollToUserMessage: (messageId: string) => messagesRef.current?.scrollToUserMessage(messageId),
}));

// Determine position based on message state
Expand Down
61 changes: 50 additions & 11 deletions apps/web/src/components/ai/shared/chat/ChatMessagesArea.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 */
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -65,6 +77,18 @@ export const ChatMessagesArea = forwardRef<ChatMessagesAreaRef, ChatMessagesArea
const scrollAreaRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [undoDialogMessageId, setUndoDialogMessageId] = useState<string | null>(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) => {
Expand All @@ -80,22 +104,37 @@ export const ChatMessagesArea = forwardRef<ChatMessagesAreaRef, ChatMessagesArea
onUndoSuccess?.();
}, [onUndoSuccess]);

// Scroll to bottom function
const scrollToBottom = () => {
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
) {
Comment on lines +121 to +125

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scroll to latest on initial load

This effect only scrolls when lastUserMessageId changes, but the ref is initialized with the current value, so on first render of an existing conversation it never fires. Since the previous unconditional scrollToBottom was removed, the viewport stays at scrollTop 0 and users see the oldest messages instead of the latest (and streaming replies can appear out of view) when opening/switching chats. Consider triggering a one-time scroll after messages finish loading or when the first non-empty message list arrives.

Useful? React with 👍 / 👎.

// 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 = () => (
Expand Down
46 changes: 27 additions & 19 deletions apps/web/src/components/ai/shared/chat/CompactMessageRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ export const CompactMessageRenderer: React.FC<CompactMessageRendererProps> = Rea
if (message.messageType === 'todo_list') {
if (isLoadingTasks) {
return (
<div className="mb-3">
<div className="mb-3" data-message-id={message.id} data-message-role={message.role}>
<div className="bg-primary/10 dark:bg-primary/20 border border-primary/20 dark:border-primary/30 rounded-md p-2">
<div className="animate-pulse">
<div className="h-3 bg-primary/30 dark:bg-primary/50 rounded w-2/3 mb-1"></div>
Expand All @@ -299,7 +299,7 @@ export const CompactMessageRenderer: React.FC<CompactMessageRendererProps> = Rea

if (!taskList || tasks.length === 0) {
return (
<div className="mb-3">
<div className="mb-3" data-message-id={message.id} data-message-role={message.role}>
<div className="bg-yellow-50 dark:bg-yellow-950/30 border border-yellow-200 dark:border-yellow-800 rounded-md p-2">
<div className="text-xs text-yellow-700 dark:text-yellow-300">
No tasks found for this todo list.
Expand All @@ -310,24 +310,26 @@ export const CompactMessageRenderer: React.FC<CompactMessageRendererProps> = Rea
}

return (
<ErrorBoundary
fallback={
<div className="mb-3">
<div className="bg-yellow-50 dark:bg-yellow-950/30 border border-yellow-200 dark:border-yellow-800 rounded-md p-2">
<div className="text-xs text-yellow-700 dark:text-yellow-300">
Failed to load TODO list. Please refresh the page.
<div data-message-id={message.id} data-message-role={message.role}>
<ErrorBoundary
fallback={
<div className="mb-3">
<div className="bg-yellow-50 dark:bg-yellow-950/30 border border-yellow-200 dark:border-yellow-800 rounded-md p-2">
<div className="text-xs text-yellow-700 dark:text-yellow-300">
Failed to load TODO list. Please refresh the page.
</div>
</div>
</div>
</div>
}
>
<CompactTodoListMessage
tasks={tasks}
taskList={taskList}
createdAt={message.createdAt}
onTaskUpdate={handleTaskStatusUpdate}
/>
</ErrorBoundary>
}
>
<CompactTodoListMessage
tasks={tasks}
taskList={taskList}
createdAt={message.createdAt}
onTaskUpdate={handleTaskStatusUpdate}
/>
</ErrorBoundary>
</div>
);
}

Expand All @@ -338,7 +340,13 @@ export const CompactMessageRenderer: React.FC<CompactMessageRendererProps> = Rea

return (
<>
<div key={message.id} className="mb-1 min-w-0 max-w-full" style={{ contentVisibility: 'auto', containIntrinsicSize: 'auto 80px' }}>
<div
key={message.id}
data-message-id={message.id}
data-message-role={message.role}
className="mb-1 min-w-0 max-w-full"
style={{ contentVisibility: 'auto', containIntrinsicSize: 'auto 80px' }}
>
{groupedParts.map((group, index) => {
if (isTextGroupPart(group)) {
const isLastTextBlock = index === groupedParts.length - 1;
Expand Down
46 changes: 27 additions & 19 deletions apps/web/src/components/ai/shared/chat/MessageRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ export const MessageRenderer: React.FC<MessageRendererProps> = React.memo(({
if (message.messageType === 'todo_list') {
if (isLoadingTasks) {
return (
<div className="mb-4 mr-8">
<div className="mb-4 mr-8" data-message-id={message.id} data-message-role={message.role}>
<div className="bg-primary/5 dark:bg-primary/10 border border-primary/20 dark:border-primary/30 rounded-lg p-4">
<div className="animate-pulse">
<div className="h-4 bg-primary/20 dark:bg-primary/30 rounded w-1/3 mb-2"></div>
Expand All @@ -301,7 +301,7 @@ export const MessageRenderer: React.FC<MessageRendererProps> = React.memo(({

if (!taskList || tasks.length === 0) {
return (
<div className="mb-4 mr-8">
<div className="mb-4 mr-8" data-message-id={message.id} data-message-role={message.role}>
<div className="bg-yellow-50 dark:bg-yellow-950/30 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<div className="text-yellow-800 dark:text-yellow-200">
No tasks found for this todo list.
Expand All @@ -312,24 +312,26 @@ export const MessageRenderer: React.FC<MessageRendererProps> = React.memo(({
}

return (
<ErrorBoundary
fallback={
<div className="mb-4">
<div className="bg-yellow-50 dark:bg-yellow-950/30 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<div className="text-yellow-800 dark:text-yellow-200">
Failed to load TODO list. Please refresh the page.
<div data-message-id={message.id} data-message-role={message.role}>
<ErrorBoundary
fallback={
<div className="mb-4">
<div className="bg-yellow-50 dark:bg-yellow-950/30 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<div className="text-yellow-800 dark:text-yellow-200">
Failed to load TODO list. Please refresh the page.
</div>
</div>
</div>
</div>
}
>
<TodoListMessage
tasks={tasks}
taskList={taskList}
createdAt={message.createdAt}
onTaskUpdate={handleTaskStatusUpdate}
/>
</ErrorBoundary>
}
>
<TodoListMessage
tasks={tasks}
taskList={taskList}
createdAt={message.createdAt}
onTaskUpdate={handleTaskStatusUpdate}
/>
</ErrorBoundary>
</div>
);
}

Expand All @@ -338,7 +340,13 @@ export const MessageRenderer: React.FC<MessageRendererProps> = React.memo(({
// ============================================
return (
<>
<div key={message.id} className="mb-2" style={{ contentVisibility: 'auto', containIntrinsicSize: 'auto 100px' }}>
<div
key={message.id}
data-message-id={message.id}
data-message-role={message.role}
className="mb-2"
style={{ contentVisibility: 'auto', containIntrinsicSize: 'auto 100px' }}
>
{groupedParts.map((group, index) => {
if (isTextGroupPart(group)) {
const isLastTextBlock = index === groupedParts.length - 1;
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/ai/shared/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading