Compare commits

...

2 Commits

Author SHA1 Message Date
Ray Myers
1c0555b558 refactor(tests): improve conversations page tests following best practices
Refactor tests to follow established testing patterns:
- Use service spies instead of MSW to avoid global handler conflicts
- Organize tests with clear describe blocks for better readability
- Extract common setup into beforeEach hooks (DRY principle)
- Use descriptive test names that read like documentation
- Use findBy queries for better async handling
- Add comments to clarify test helpers and setup

All 16 tests passing with improved structure and clarity.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 16:22:50 -06:00
Ray Myers
e80317c9bb feat(frontend): add conversations list page
Add a new /conversations route that displays a full list of user conversations
with infinite scroll pagination. The page reuses existing conversation list
components and provides the same information as the sidebar conversations tab
but in a full-page layout.

Changes:
- Add /conversations route to routes.ts
- Create conversations.tsx page component with infinite scroll
- Add comprehensive test suite with 14 tests covering all major functionality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 16:03:49 -06:00
3 changed files with 451 additions and 0 deletions

View 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);
});
});
});
});

View File

@@ -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"),

View 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;