mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-09 14:57:59 -05:00
Implement auto-pagination for conversation list with infinite scroll (#10129)
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
This commit is contained in:
@@ -85,9 +85,10 @@ describe("ConversationPanel", () => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
// Setup default mock for getUserConversations
|
||||
vi.spyOn(OpenHands, "getUserConversations").mockResolvedValue([
|
||||
...mockConversations,
|
||||
]);
|
||||
vi.spyOn(OpenHands, "getUserConversations").mockResolvedValue({
|
||||
results: [...mockConversations],
|
||||
next_page_id: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the conversations", async () => {
|
||||
@@ -101,7 +102,10 @@ describe("ConversationPanel", () => {
|
||||
|
||||
it("should display an empty state when there are no conversations", async () => {
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockResolvedValue([]);
|
||||
getUserConversationsSpy.mockResolvedValue({
|
||||
results: [],
|
||||
next_page_id: null,
|
||||
});
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
@@ -195,7 +199,10 @@ describe("ConversationPanel", () => {
|
||||
];
|
||||
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockImplementation(async () => mockData);
|
||||
getUserConversationsSpy.mockImplementation(async () => ({
|
||||
results: mockData,
|
||||
next_page_id: null,
|
||||
}));
|
||||
|
||||
const deleteUserConversationSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
@@ -249,7 +256,10 @@ describe("ConversationPanel", () => {
|
||||
it("should refetch data on rerenders", async () => {
|
||||
const user = userEvent.setup();
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockResolvedValue([...mockConversations]);
|
||||
getUserConversationsSpy.mockResolvedValue({
|
||||
results: [...mockConversations],
|
||||
next_page_id: null,
|
||||
});
|
||||
|
||||
function PanelWithToggle() {
|
||||
const [isOpen, setIsOpen] = React.useState(true);
|
||||
@@ -343,7 +353,10 @@ describe("ConversationPanel", () => {
|
||||
];
|
||||
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockResolvedValue(mockRunningConversations);
|
||||
getUserConversationsSpy.mockResolvedValue({
|
||||
results: mockRunningConversations,
|
||||
next_page_id: null,
|
||||
});
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
@@ -407,7 +420,10 @@ describe("ConversationPanel", () => {
|
||||
];
|
||||
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockImplementation(async () => mockData);
|
||||
getUserConversationsSpy.mockImplementation(async () => ({
|
||||
results: mockData,
|
||||
next_page_id: null,
|
||||
}));
|
||||
|
||||
const stopConversationSpy = vi.spyOn(OpenHands, "stopConversation");
|
||||
stopConversationSpy.mockImplementation(async (id: string) => {
|
||||
@@ -492,7 +508,10 @@ describe("ConversationPanel", () => {
|
||||
];
|
||||
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockResolvedValue(mockMixedStatusConversations);
|
||||
getUserConversationsSpy.mockResolvedValue({
|
||||
results: mockMixedStatusConversations,
|
||||
next_page_id: null,
|
||||
});
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
|
||||
@@ -283,17 +283,27 @@ class OpenHands {
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getUserConversations(): Promise<Conversation[]> {
|
||||
static async getUserConversations(
|
||||
limit: number = 20,
|
||||
pageId?: string,
|
||||
): Promise<ResultSet<Conversation>> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("limit", limit.toString());
|
||||
|
||||
if (pageId) {
|
||||
params.append("page_id", pageId);
|
||||
}
|
||||
|
||||
const { data } = await openHands.get<ResultSet<Conversation>>(
|
||||
"/api/conversations?limit=100",
|
||||
`/api/conversations?${params.toString()}`,
|
||||
);
|
||||
return data.results;
|
||||
return data;
|
||||
}
|
||||
|
||||
static async searchConversations(
|
||||
selectedRepository?: string,
|
||||
conversationTrigger?: string,
|
||||
limit: number = 20,
|
||||
limit: number = 100,
|
||||
): Promise<Conversation[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("limit", limit.toString());
|
||||
|
||||
@@ -3,7 +3,8 @@ import { NavLink, useParams, useNavigate } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { ConversationCard } from "./conversation-card";
|
||||
import { useUserConversations } from "#/hooks/query/use-user-conversations";
|
||||
import { usePaginatedConversations } from "#/hooks/query/use-paginated-conversations";
|
||||
import { useInfiniteScroll } from "#/hooks/use-infinite-scroll";
|
||||
import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation";
|
||||
import { useStopConversation } from "#/hooks/mutation/use-stop-conversation";
|
||||
import { ConfirmDeleteModal } from "./confirm-delete-modal";
|
||||
@@ -40,12 +41,30 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const { data: conversations, isFetching, error } = useUserConversations();
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
error,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
} = usePaginatedConversations();
|
||||
|
||||
// Flatten all pages into a single array of conversations
|
||||
const conversations = data?.pages.flatMap((page) => page.results) ?? [];
|
||||
|
||||
const { mutate: deleteConversation } = useDeleteConversation();
|
||||
const { mutate: stopConversation } = useStopConversation();
|
||||
const { mutate: updateConversation } = useUpdateConversation();
|
||||
|
||||
// Set up infinite scroll
|
||||
const scrollContainerRef = useInfiniteScroll({
|
||||
hasNextPage: !!hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
threshold: 200, // Load more when 200px from bottom
|
||||
});
|
||||
|
||||
const handleDeleteProject = (conversationId: string) => {
|
||||
setConfirmDeleteModalVisible(true);
|
||||
setSelectedConversationId(conversationId);
|
||||
@@ -102,11 +121,16 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
ref={(node) => {
|
||||
// TODO: Combine both refs somehow
|
||||
if (ref.current !== node) ref.current = node;
|
||||
if (scrollContainerRef.current !== node)
|
||||
scrollContainerRef.current = node;
|
||||
}}
|
||||
data-testid="conversation-panel"
|
||||
className="w-[350px] h-full border border-neutral-700 bg-base-secondary rounded-xl overflow-y-auto absolute"
|
||||
>
|
||||
{isFetching && (
|
||||
{isFetching && conversations.length === 0 && (
|
||||
<div className="w-full h-full absolute flex justify-center items-center">
|
||||
<LoadingSpinner size="small" />
|
||||
</div>
|
||||
@@ -156,6 +180,13 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{/* Loading indicator for fetching more conversations */}
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex justify-center py-4">
|
||||
<LoadingSpinner size="small" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmDeleteModalVisible && (
|
||||
<ConfirmDeleteModal
|
||||
onConfirm={() => {
|
||||
|
||||
16
frontend/src/hooks/query/use-paginated-conversations.ts
Normal file
16
frontend/src/hooks/query/use-paginated-conversations.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useIsAuthed } from "./use-is-authed";
|
||||
|
||||
export const usePaginatedConversations = (limit: number = 20) => {
|
||||
const { data: userIsAuthenticated } = useIsAuthed();
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["user", "conversations", "paginated", limit],
|
||||
queryFn: ({ pageParam }) =>
|
||||
OpenHands.getUserConversations(limit, pageParam),
|
||||
enabled: !!userIsAuthenticated,
|
||||
getNextPageParam: (lastPage) => lastPage.next_page_id,
|
||||
initialPageParam: undefined as string | undefined,
|
||||
});
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import OpenHands from "#/api/open-hands";
|
||||
export const useSearchConversations = (
|
||||
selectedRepository?: string,
|
||||
conversationTrigger?: string,
|
||||
limit: number = 20,
|
||||
limit: number = 100,
|
||||
cacheDisabled: boolean = false,
|
||||
) =>
|
||||
useQuery({
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useIsAuthed } from "./use-is-authed";
|
||||
|
||||
export const useUserConversations = () => {
|
||||
const { data: userIsAuthenticated } = useIsAuthed();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["user", "conversations"],
|
||||
queryFn: OpenHands.getUserConversations,
|
||||
enabled: !!userIsAuthenticated,
|
||||
});
|
||||
};
|
||||
42
frontend/src/hooks/use-infinite-scroll.ts
Normal file
42
frontend/src/hooks/use-infinite-scroll.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
|
||||
interface UseInfiniteScrollOptions {
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
fetchNextPage: () => void;
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export const useInfiniteScroll = ({
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
threshold = 100,
|
||||
}: UseInfiniteScrollOptions) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!containerRef.current || isFetchingNextPage || !hasNextPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||
const isNearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
|
||||
|
||||
if (isNearBottom) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage, threshold]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return undefined;
|
||||
|
||||
container.addEventListener("scroll", handleScroll);
|
||||
return () => {
|
||||
container.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, [handleScroll]);
|
||||
|
||||
return containerRef;
|
||||
};
|
||||
Reference in New Issue
Block a user