Compare commits

...

3 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
Wang Siyuan
d90579b398 fix: make local runtime use host-writable paths and local cache defaults (#12015)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2025-12-18 17:31:12 +01:00
5 changed files with 484 additions and 1 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;

View File

@@ -1,8 +1,10 @@
import atexit
import json
import multiprocessing
import os
import time
import uuid
from pathlib import Path
import browsergym.core # noqa F401 (we register the openended task as a gym environment)
import gymnasium as gym
@@ -67,6 +69,16 @@ class BrowserEnv:
raise BrowserInitException('Failed to start browser environment.')
def browser_process(self) -> None:
def _is_local_runtime() -> bool:
runtime_flag = os.getenv('RUNTIME', '').lower()
return runtime_flag == 'local'
# Default Playwright cache for local runs only; do not override in docker
if _is_local_runtime() and 'PLAYWRIGHT_BROWSERS_PATH' not in os.environ:
os.environ['PLAYWRIGHT_BROWSERS_PATH'] = str(
Path.home() / '.cache' / 'playwright'
)
if self.eval_mode:
assert self.browsergym_eval_env is not None
logger.info('Initializing browser env for web browsing evaluation.')
@@ -87,6 +99,11 @@ class BrowserEnv:
)
env = gym.make(self.browsergym_eval_env, tags_to_mark='all', timeout=100000)
else:
downloads_path = os.getenv('BROWSERGYM_DOWNLOAD_DIR')
if not downloads_path and _is_local_runtime():
downloads_path = str(Path.home() / '.cache' / 'browsergym-downloads')
if not downloads_path:
downloads_path = '/workspace/.downloads/'
env = gym.make(
'browsergym/openended',
task_kwargs={'start_url': 'about:blank', 'goal': 'PLACEHOLDER_GOAL'},
@@ -96,7 +113,7 @@ class BrowserEnv:
tags_to_mark='all',
timeout=100000,
pw_context_kwargs={'accept_downloads': True},
pw_chromium_kwargs={'downloads_path': '/workspace/.downloads/'},
pw_chromium_kwargs={'downloads_path': downloads_path},
)
obs, info = env.reset()

View File

@@ -249,7 +249,22 @@ class LocalRuntime(ActionExecutionClient):
)
else:
# Set up workspace directory
# For local runtime, prefer a stable host path over /workspace defaults.
if (
self.config.workspace_base is None
and self.config.runtime
and self.config.runtime.lower() == 'local'
):
env_base = os.getenv('LOCAL_WORKSPACE_BASE')
if env_base:
self.config.workspace_base = os.path.abspath(env_base)
else:
self.config.workspace_base = os.path.abspath(
os.path.join(os.getcwd(), 'workspace', 'local')
)
if self.config.workspace_base is not None:
os.makedirs(self.config.workspace_base, exist_ok=True)
logger.warning(
f'Workspace base path is set to {self.config.workspace_base}. '
'It will be used as the path for the agent to run in. '