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
158 changes: 151 additions & 7 deletions agentex-ui/components/task-messages/task-messages.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { Fragment, memo, useEffect, useMemo, useRef, useState } from 'react';
import {
Fragment,
memo,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';

import { AnimatePresence, motion } from 'framer-motion';

Expand All @@ -9,7 +17,8 @@ import { TaskMessageScrollContainer } from '@/components/task-messages/task-mess
import { TaskMessageTextContent } from '@/components/task-messages/task-message-text-content';
import { TaskMessageToolPair } from '@/components/task-messages/task-message-tool-pair';
import { ShimmeringText } from '@/components/ui/shimmering-text';
import { useTaskMessages } from '@/hooks/use-task-messages';
import { Spinner } from '@/components/ui/spinner';
import { useInfiniteTaskMessages } from '@/hooks/use-infinite-task-messages';

import type {
TaskMessage,
Expand All @@ -30,13 +39,42 @@ type MessagePair = {
function TaskMessagesImpl({ taskId, headerRef }: TaskMessagesProps) {
const lastPairRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLElement | null>(null);
const [containerHeight, setContainerHeight] = useState<number>(0);
// Prevent IntersectionObserver from triggering during initial load
const [isInitialScrollComplete, setIsInitialScrollComplete] = useState(false);
const hasScrolledToBottomRef = useRef(false);
// Track which taskId we last scrolled for (to handle task switching)
const lastScrolledTaskIdRef = useRef<string | null>(null);
// For scroll position preservation when loading older messages
const previousScrollHeightRef = useRef<number>(0);
const wasFetchingRef = useRef(false);

const { agentexClient } = useAgentexClient();

const { data: queryData } = useTaskMessages({ agentexClient, taskId });
const {
data: queryData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteTaskMessages({ agentexClient, taskId });

const messages = useMemo(() => queryData?.messages ?? [], [queryData]);

// Reset scroll state when switching tasks
useEffect(() => {
hasScrolledToBottomRef.current = false;
setIsInitialScrollComplete(false);
previousMessageCountRef.current = 0;
// Don't reset lastScrolledTaskIdRef here - it's used to detect task changes
}, [taskId]);

// Check if any message is currently streaming
const hasStreamingMessage = useMemo(
() => messages.some(msg => msg.streaming_status === 'IN_PROGRESS'),
[messages]
);
const previousMessageCountRef = useRef(messages.length);

const toolCallIdToResponseMap = useMemo<
Expand Down Expand Up @@ -101,12 +139,43 @@ function TaskMessagesImpl({ taskId, headerRef }: TaskMessagesProps) {

const lastPair = messagePairs[messagePairs.length - 1]!;
const hasNoAgentMessages = lastPair.agentMessages.length === 0;
const rpcStatus = queryData?.rpcStatus;
const lastUserMessageIsRecent =
lastPair.userMessage.content.author === 'user';

// Show thinking only when:
// - User sent the last message AND no agent response yet AND nothing is streaming
// Once streaming starts or agent responds, hide the thinking indicator
return (
hasNoAgentMessages && (rpcStatus === 'pending' || rpcStatus === 'success')
hasNoAgentMessages && lastUserMessageIsRecent && !hasStreamingMessage
);
}, [messagePairs, hasStreamingMessage]);

// Use IntersectionObserver to load more when sentinel becomes visible
// Only enable after initial scroll to bottom is complete to avoid unwanted fetches
useEffect(() => {
const sentinel = loadMoreRef.current;
if (!sentinel || !isInitialScrollComplete) {
return;
}

const observer = new IntersectionObserver(
entries => {
const entry = entries[0];
if (entry?.isIntersecting && hasNextPage && !isFetchingNextPage) {
// Save scroll height BEFORE fetching so we can restore position after
if (scrollContainerRef.current) {
previousScrollHeightRef.current =
scrollContainerRef.current.scrollHeight;
}
fetchNextPage();
}
},
{ threshold: 0.1, rootMargin: '200px 0px 0px 0px' }
);
}, [messagePairs, queryData?.rpcStatus]);

observer.observe(sentinel);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage, isInitialScrollComplete]);

useEffect(() => {
const measureHeight = () => {
Expand All @@ -115,6 +184,8 @@ function TaskMessagesImpl({ taskId, headerRef }: TaskMessagesProps) {
while (element) {
const overflowY = window.getComputedStyle(element).overflowY;
if (overflowY === 'auto' || overflowY === 'scroll') {
// Store reference to scroll container for position preservation
scrollContainerRef.current = element;
setContainerHeight(
element.clientHeight - (headerRef.current?.clientHeight ?? 0)
);
Expand All @@ -131,11 +202,67 @@ function TaskMessagesImpl({ taskId, headerRef }: TaskMessagesProps) {
return () => window.removeEventListener('resize', measureHeight);
}, [headerRef, messages]);

// Preserve scroll position when older messages are loaded
// This runs BEFORE paint to prevent visual jumping
useLayoutEffect(() => {
// Detect when fetching completes (was fetching, now not fetching)
if (wasFetchingRef.current && !isFetchingNextPage) {
const scrollContainer = scrollContainerRef.current;
const previousScrollHeight = previousScrollHeightRef.current;

if (scrollContainer && previousScrollHeight > 0) {
const newScrollHeight = scrollContainer.scrollHeight;
const heightDifference = newScrollHeight - previousScrollHeight;

if (heightDifference > 0) {
// Adjust scroll position by the height of newly added content
scrollContainer.scrollTop += heightDifference;
}

// Reset for next pagination
previousScrollHeightRef.current = 0;
}
}

wasFetchingRef.current = isFetchingNextPage;
}, [isFetchingNextPage]);

// Initial scroll: use useLayoutEffect to scroll BEFORE browser paints
// This prevents the user from seeing the scroll animation on first load
useLayoutEffect(() => {
// Check if we need to scroll: either first load OR task changed
const needsScroll =
!hasScrolledToBottomRef.current ||
lastScrolledTaskIdRef.current !== taskId;

if (needsScroll && messagePairs.length > 0 && lastPairRef.current) {
// Scroll instantly before paint
lastPairRef.current.scrollIntoView({
behavior: 'instant',
block: 'end',
});

hasScrolledToBottomRef.current = true;
lastScrolledTaskIdRef.current = taskId;

// Enable IntersectionObserver after a short delay
setTimeout(() => {
setIsInitialScrollComplete(true);
}, 300);
}
}, [messagePairs.length, taskId]);

// Subsequent new messages: smooth scroll (using regular useEffect)
useEffect(() => {
const previousCount = previousMessageCountRef.current;
const currentCount = messagePairs.length;

if (currentCount > previousCount && lastPairRef.current) {
// Only handle NEW messages after initial load
if (
hasScrolledToBottomRef.current &&
currentCount > previousCount &&
lastPairRef.current
) {
setTimeout(() => {
lastPairRef.current?.scrollIntoView({
behavior: 'smooth',
Expand Down Expand Up @@ -179,6 +306,23 @@ function TaskMessagesImpl({ taskId, headerRef }: TaskMessagesProps) {
ref={containerRef}
className="flex w-full flex-1 flex-col items-center"
>
{/* Sentinel for IntersectionObserver - triggers loading older messages */}
<div ref={loadMoreRef} className="h-1 w-full" />

{/* Loading indicator for older messages */}
{isFetchingNextPage && (
<div className="flex justify-center py-4">
<Spinner className="text-muted-foreground h-5 w-5" />
</div>
Comment on lines +314 to +316
Copy link
Collaborator

Choose a reason for hiding this comment

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

We already have an implementation of a loading spinner for infinite scrolling in the task-sidebar — lets choose one or the other to keep it consistent

)}

{/* Indicator when all history is loaded */}
{!hasNextPage && messages.length > 0 && (
<div className="text-muted-foreground py-4 text-center text-xs">
Beginning of conversation
</div>
)}

{messagePairs.map((pair, index) => {
const isLastPair = index === messagePairs.length - 1;
const shouldShowThinking = isLastPair && shouldShowThinkingForLastPair;
Expand Down
30 changes: 30 additions & 0 deletions agentex-ui/components/ui/spinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { cn } from '@/lib/utils';

type SpinnerProps = {
className?: string;
};

export function Spinner({ className }: SpinnerProps) {
return (
<svg
className={cn('animate-spin', className)}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
}
84 changes: 84 additions & 0 deletions agentex-ui/hooks/use-infinite-task-messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { useInfiniteQuery } from '@tanstack/react-query';

import type AgentexSDK from 'agentex';
import type {
MessageListPaginatedResponse,
TaskMessage,
} from 'agentex/resources';

// Re-export for use in other hooks
export type { MessageListPaginatedResponse };

export const infiniteTaskMessagesKeys = {
all: ['infiniteTaskMessages'] as const,
byTaskId: (taskId: string) =>
[...infiniteTaskMessagesKeys.all, taskId] as const,
};

export type InfiniteTaskMessagesData = {
messages: TaskMessage[];
hasMore: boolean;
};

/**
* Fetches task messages with infinite scroll pagination.
*
* Uses cursor-based pagination to efficiently load older messages
* as the user scrolls up. Messages are returned in chronological order
* (oldest first) for display.
*
* @param agentexClient - AgentexSDK - The SDK client used to fetch messages
* @param taskId - string - The unique ID of the task whose messages to retrieve
* @param limit - number - Number of messages to fetch per page (default: 50)
* @returns UseInfiniteQueryResult with messages and pagination controls
*
* @example
* const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
* useInfiniteTaskMessages({ agentexClient, taskId });
*
* // Load more older messages when user scrolls to top
* if (hasNextPage && !isFetchingNextPage) {
* fetchNextPage();
* }
*/
export function useInfiniteTaskMessages({
agentexClient,
taskId,
limit = 50,
}: {
agentexClient: AgentexSDK;
taskId: string;
limit?: number;
}) {
return useInfiniteQuery({
queryKey: infiniteTaskMessagesKeys.byTaskId(taskId),
queryFn: async ({ pageParam }): Promise<MessageListPaginatedResponse> => {
return agentexClient.messages.listPaginated({
task_id: taskId,
limit,
direction: 'older',
...(pageParam && { cursor: pageParam }),
});
},
getNextPageParam: lastPage => {
// Return next_cursor if there are more messages
return lastPage.has_more ? lastPage.next_cursor : undefined;
},
initialPageParam: undefined as string | undefined,
enabled: !!taskId,
// Transform pages into a flat, chronologically ordered array
select: data => {
// Flatten all pages and reverse to get chronological order
const allMessages = data.pages.flatMap(page => page.data);
// Messages come newest first, so reverse for chronological order
const chronologicalMessages = allMessages.slice().reverse();

return {
pages: data.pages,
pageParams: data.pageParams,
messages: chronologicalMessages,
hasMore: data.pages[data.pages.length - 1]?.has_more ?? false,
};
},
});
}
Loading