mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
2 Commits
prototype/
...
ray/conver
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c0555b558 | ||
|
|
e80317c9bb |
295
frontend/__tests__/routes/conversations.test.tsx
Normal file
295
frontend/__tests__/routes/conversations.test.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import ConversationsPage from "#/routes/conversations";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { Conversation, ResultSet } from "#/api/open-hands.types";
|
||||
|
||||
// Mock conversation data for testing
|
||||
const MOCK_CONVERSATIONS: Conversation[] = [
|
||||
{
|
||||
conversation_id: "conv-1",
|
||||
title: "Fix authentication bug",
|
||||
status: "RUNNING",
|
||||
selected_repository: "octocat/hello-world",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
created_at: "2025-12-17T10:00:00Z",
|
||||
last_updated_at: "2025-12-17T10:30:00Z",
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
},
|
||||
{
|
||||
conversation_id: "conv-2",
|
||||
title: "Add dark mode feature",
|
||||
status: "STOPPED",
|
||||
selected_repository: "octocat/my-repo",
|
||||
selected_branch: "feature/dark-mode",
|
||||
git_provider: "gitlab",
|
||||
created_at: "2025-12-16T14:00:00Z",
|
||||
last_updated_at: "2025-12-16T15:00:00Z",
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
},
|
||||
{
|
||||
conversation_id: "conv-3",
|
||||
title: "Refactor API endpoints",
|
||||
status: "ERROR",
|
||||
selected_repository: null,
|
||||
selected_branch: null,
|
||||
git_provider: null,
|
||||
created_at: "2025-12-15T09:00:00Z",
|
||||
last_updated_at: "2025-12-15T09:45:00Z",
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
},
|
||||
];
|
||||
|
||||
// Test helper to create ResultSet responses
|
||||
const createResultSet = (
|
||||
conversations: Conversation[],
|
||||
nextPageId: string | null = null,
|
||||
): ResultSet<Conversation> => ({
|
||||
results: conversations,
|
||||
next_page_id: nextPageId,
|
||||
});
|
||||
|
||||
// Router stub for navigation
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: ConversationsPage,
|
||||
path: "/conversations",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="conversation-detail" />,
|
||||
path: "/conversations/:conversationId",
|
||||
},
|
||||
]);
|
||||
|
||||
// Render helper with QueryClient
|
||||
const renderConversationsPage = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return render(<RouterStub initialEntries={["/conversations"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
describe("Conversations Page", () => {
|
||||
const getUserConversationsSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"getUserConversations",
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// Default: Return mock conversations
|
||||
getUserConversationsSpy.mockResolvedValue(
|
||||
createResultSet(MOCK_CONVERSATIONS),
|
||||
);
|
||||
});
|
||||
|
||||
describe("Page Header", () => {
|
||||
it("displays the recent conversations title", async () => {
|
||||
renderConversationsPage();
|
||||
|
||||
expect(
|
||||
await screen.findByText("COMMON$RECENT_CONVERSATIONS"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Loading State", () => {
|
||||
it("shows skeleton loader then conversations", async () => {
|
||||
renderConversationsPage();
|
||||
|
||||
// Conversations should appear after loading
|
||||
expect(
|
||||
await screen.findByText("Fix authentication bug"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Conversations List", () => {
|
||||
it("displays all conversations with titles", async () => {
|
||||
renderConversationsPage();
|
||||
|
||||
expect(
|
||||
await screen.findByText("Fix authentication bug"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Add dark mode feature")).toBeInTheDocument();
|
||||
expect(screen.getByText("Refactor API endpoints")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows repository and branch information", async () => {
|
||||
renderConversationsPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("octocat/hello-world")).toBeInTheDocument();
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("octocat/my-repo")).toBeInTheDocument();
|
||||
expect(screen.getByText("feature/dark-mode")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays no repository label when repository is not set", async () => {
|
||||
renderConversationsPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("COMMON$NO_REPOSITORY")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows status indicators for each conversation state", async () => {
|
||||
renderConversationsPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("COMMON$RUNNING")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("COMMON$STOPPED")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("COMMON$ERROR")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays relative timestamps", async () => {
|
||||
renderConversationsPage();
|
||||
|
||||
await waitFor(() => {
|
||||
const timestamps = screen.getAllByText(/CONVERSATION\$AGO/);
|
||||
expect(timestamps.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Empty State", () => {
|
||||
it("shows empty message when no conversations exist", async () => {
|
||||
getUserConversationsSpy.mockResolvedValue(createResultSet([]));
|
||||
|
||||
renderConversationsPage();
|
||||
|
||||
expect(
|
||||
await screen.findByText("HOME$NO_RECENT_CONVERSATIONS"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show empty state when there is an error", async () => {
|
||||
getUserConversationsSpy.mockRejectedValue(
|
||||
new Error("Network error"),
|
||||
);
|
||||
|
||||
renderConversationsPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Network error/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByText("HOME$NO_RECENT_CONVERSATIONS"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("displays error message when API request fails", async () => {
|
||||
getUserConversationsSpy.mockRejectedValue(
|
||||
new Error("Failed to fetch conversations"),
|
||||
);
|
||||
|
||||
renderConversationsPage();
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Failed to fetch conversations/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pagination", () => {
|
||||
it("loads first page of conversations", async () => {
|
||||
const firstPageConversations = MOCK_CONVERSATIONS.slice(0, 2);
|
||||
|
||||
getUserConversationsSpy.mockResolvedValue(
|
||||
createResultSet(firstPageConversations, "page-2"),
|
||||
);
|
||||
|
||||
renderConversationsPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Fix authentication bug")).toBeInTheDocument();
|
||||
expect(screen.getByText("Add dark mode feature")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Third conversation not on first page
|
||||
expect(
|
||||
screen.queryByText("Refactor API endpoints"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show loading indicator when not fetching", async () => {
|
||||
renderConversationsPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Fix authentication bug")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText(/Loading more/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Navigation", () => {
|
||||
it("links to individual conversation detail page", async () => {
|
||||
renderConversationsPage();
|
||||
|
||||
const conversationLink = await screen.findByText("Fix authentication bug");
|
||||
const linkElement = conversationLink.closest("a");
|
||||
|
||||
expect(linkElement).toHaveAttribute("href", "/conversations/conv-1");
|
||||
});
|
||||
|
||||
it("creates clickable cards for each conversation", async () => {
|
||||
renderConversationsPage();
|
||||
|
||||
await waitFor(() => {
|
||||
const links = screen.getAllByRole("link");
|
||||
expect(links.length).toBe(MOCK_CONVERSATIONS.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("API Integration", () => {
|
||||
it("requests conversations with page size of 20", async () => {
|
||||
renderConversationsPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Fix authentication bug")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(getUserConversationsSpy).toHaveBeenCalledWith(20, undefined);
|
||||
});
|
||||
|
||||
it("supports pagination with page_id parameter", async () => {
|
||||
const firstPageConversations = MOCK_CONVERSATIONS.slice(0, 2);
|
||||
|
||||
getUserConversationsSpy.mockResolvedValueOnce(
|
||||
createResultSet(firstPageConversations, "page-2"),
|
||||
);
|
||||
|
||||
renderConversationsPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getUserConversationsSpy).toHaveBeenCalledWith(20, undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ export default [
|
||||
route("secrets", "routes/secrets-settings.tsx"),
|
||||
route("api-keys", "routes/api-keys.tsx"),
|
||||
]),
|
||||
route("conversations", "routes/conversations.tsx"),
|
||||
route("conversations/:conversationId", "routes/conversation.tsx"),
|
||||
route("microagent-management", "routes/microagent-management.tsx"),
|
||||
route("oauth/device/verify", "routes/device-verify.tsx"),
|
||||
|
||||
155
frontend/src/routes/conversations.tsx
Normal file
155
frontend/src/routes/conversations.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import CodeBranchIcon from "#/icons/u-code-branch.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { usePaginatedConversations } from "#/hooks/query/use-paginated-conversations";
|
||||
import { useInfiniteScroll } from "#/hooks/use-infinite-scroll";
|
||||
import { RecentConversationsSkeleton } from "#/components/features/home/recent-conversations/recent-conversations-skeleton";
|
||||
import { GitProviderIcon } from "#/components/shared/git-provider-icon";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { ConversationStatusIndicator } from "#/components/features/home/recent-conversations/conversation-status-indicator";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
import RepoForkedIcon from "#/icons/repo-forked.svg?react";
|
||||
|
||||
interface ConversationItemProps {
|
||||
conversation: Conversation;
|
||||
}
|
||||
|
||||
function ConversationItem({ conversation }: ConversationItemProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasRepository =
|
||||
conversation.selected_repository && conversation.selected_branch;
|
||||
|
||||
return (
|
||||
<Link to={`/conversations/${conversation.conversation_id}`}>
|
||||
<div className="flex flex-col gap-1 p-4 cursor-pointer w-full rounded-lg hover:bg-[#5C5D62] transition-all duration-300 border border-[#525252]">
|
||||
<div className="flex items-center gap-2">
|
||||
<ConversationStatusIndicator
|
||||
conversationStatus={conversation.status}
|
||||
/>
|
||||
<span className="text-sm text-white leading-6 font-medium">
|
||||
{conversation.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-[#A3A3A3] leading-4 font-normal">
|
||||
<div className="flex items-center gap-3">
|
||||
{hasRepository ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<GitProviderIcon
|
||||
gitProvider={conversation.git_provider as Provider}
|
||||
/>
|
||||
<span
|
||||
className="max-w-[200px] truncate"
|
||||
title={conversation.selected_repository || ""}
|
||||
>
|
||||
{conversation.selected_repository}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<RepoForkedIcon width={12} height={12} color="#A3A3A3" />
|
||||
<span className="max-w-[200px] truncate">
|
||||
{t(I18nKey.COMMON$NO_REPOSITORY)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasRepository ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<CodeBranchIcon width={12} height={12} color="#A3A3A3" />
|
||||
<span
|
||||
className="max-w-[200px] truncate"
|
||||
title={conversation.selected_branch || ""}
|
||||
>
|
||||
{conversation.selected_branch}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{(conversation.created_at || conversation.last_updated_at) && (
|
||||
<span>
|
||||
{formatTimeDelta(
|
||||
conversation.created_at || conversation.last_updated_at,
|
||||
)}{" "}
|
||||
{t(I18nKey.CONVERSATION$AGO)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
data: conversationsList,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
error,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = usePaginatedConversations(20);
|
||||
|
||||
const scrollContainerRef = useInfiniteScroll({
|
||||
hasNextPage: !!hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
threshold: 200,
|
||||
});
|
||||
|
||||
const conversations =
|
||||
conversationsList?.pages.flatMap((page) => page.results) ?? [];
|
||||
|
||||
const isInitialLoading = isFetching && !conversationsList;
|
||||
|
||||
return (
|
||||
<div className="px-6 py-8 bg-transparent h-full flex flex-col overflow-y-auto">
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
{t(I18nKey.COMMON$RECENT_CONVERSATIONS)}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<p className="text-danger">{error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isInitialLoading && (
|
||||
<div className="max-w-4xl">
|
||||
<RecentConversationsSkeleton />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isInitialLoading && !error && conversations.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<p className="text-sm text-[#A3A3A3]">
|
||||
{t(I18nKey.HOME$NO_RECENT_CONVERSATIONS)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isInitialLoading && conversations.length > 0 && (
|
||||
<div ref={scrollContainerRef} className="flex flex-col gap-2 max-w-4xl">
|
||||
{conversations.map((conversation) => (
|
||||
<ConversationItem
|
||||
key={conversation.conversation_id}
|
||||
conversation={conversation}
|
||||
/>
|
||||
))}
|
||||
{isFetchingNextPage && (
|
||||
<div className="py-4 text-center text-sm text-[#A3A3A3]">
|
||||
{t(I18nKey.HOME$LOADING)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Reference in New Issue
Block a user