feat(frontend): Add GitHub Issues/PRs management page

This adds a new page to the OpenHands frontend for managing GitHub issues
and pull requests. The page allows users to:

1. View issues and PRs from their connected GitHub account
2. Filter by type (issues, PRs, or all)
3. Filter by assignment (assigned to me, authored by me)
4. Start a new session to work on an issue or PR
5. Resume an existing session if one is already working on that item

Features:
- View type selector (All/Issues/Pull Requests)
- Filter checkboxes for 'assigned to me' and 'authored by me'
- Local storage caching with 1-minute refresh interval
- Manual refresh button
- Start Session button to create a new conversation for an issue/PR
- Resume Session button to continue an existing conversation
- Status badges showing item state (Open Issue, Open PR, etc.)
- Links to view items on GitHub

Technical implementation:
- New route at /github-issues-prs
- API service using existing /api/user/suggested-tasks endpoint
- React Query hook with localStorage caching
- Sidebar navigation button
- Comprehensive unit tests (24 tests)
- i18n translations for all UI text

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
openhands 2025-12-21 04:03:21 +00:00
parent 6605070d05
commit ad47b3d590
11 changed files with 1559 additions and 0 deletions

View File

@ -0,0 +1,176 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import {
useGitHubIssuesPRs,
useRefreshGitHubIssuesPRs,
} from "../src/hooks/query/use-github-issues-prs";
import { useShouldShowUserFeatures } from "../src/hooks/use-should-show-user-features";
import GitHubIssuesPRsService from "../src/api/github-service/github-issues-prs.api";
// Mock the dependencies
vi.mock("../src/hooks/use-should-show-user-features");
vi.mock("../src/api/github-service/github-issues-prs.api", () => ({
default: {
getGitHubItems: vi.fn(),
buildItemUrl: vi.fn(),
},
}));
const mockUseShouldShowUserFeatures = vi.mocked(useShouldShowUserFeatures);
const mockGetGitHubItems = vi.mocked(GitHubIssuesPRsService.getGitHubItems);
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
};
describe("useGitHubIssuesPRs", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockUseShouldShowUserFeatures.mockReturnValue(false);
});
afterEach(() => {
localStorage.clear();
});
it("should be disabled when useShouldShowUserFeatures returns false", () => {
mockUseShouldShowUserFeatures.mockReturnValue(false);
const { result } = renderHook(() => useGitHubIssuesPRs(), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(false);
expect(result.current.isFetching).toBe(false);
});
it("should be enabled when useShouldShowUserFeatures returns true", () => {
mockUseShouldShowUserFeatures.mockReturnValue(true);
mockGetGitHubItems.mockResolvedValue({
items: [],
cached_at: new Date().toISOString(),
});
const { result } = renderHook(() => useGitHubIssuesPRs(), {
wrapper: createWrapper(),
});
// When enabled, the query should be loading/fetching
expect(result.current.isLoading).toBe(true);
});
it("should fetch and return GitHub items", async () => {
mockUseShouldShowUserFeatures.mockReturnValue(true);
const mockItems = [
{
git_provider: "github" as const,
item_type: "issue" as const,
status: "OPEN_ISSUE" as const,
repo: "test/repo",
number: 1,
title: "Test Issue",
author: "testuser",
assignees: ["testuser"],
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
url: "https://github.com/test/repo/issues/1",
},
];
mockGetGitHubItems.mockResolvedValue({
items: mockItems,
cached_at: new Date().toISOString(),
});
const { result } = renderHook(() => useGitHubIssuesPRs(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.items).toEqual(mockItems);
});
it("should filter by item type", async () => {
mockUseShouldShowUserFeatures.mockReturnValue(true);
const mockItems = [
{
git_provider: "github" as const,
item_type: "issue" as const,
status: "OPEN_ISSUE" as const,
repo: "test/repo",
number: 1,
title: "Test Issue",
author: "testuser",
assignees: ["testuser"],
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
url: "https://github.com/test/repo/issues/1",
},
];
mockGetGitHubItems.mockResolvedValue({
items: mockItems,
cached_at: new Date().toISOString(),
});
const { result } = renderHook(
() => useGitHubIssuesPRs({ itemType: "issues" }),
{
wrapper: createWrapper(),
},
);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(mockGetGitHubItems).toHaveBeenCalledWith({ itemType: "issues" });
});
});
describe("useRefreshGitHubIssuesPRs", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it("should clear localStorage cache when called", () => {
// Set up some cached data
localStorage.setItem(
"github-issues-prs-cache",
JSON.stringify({
data: { items: [], cached_at: new Date().toISOString() },
timestamp: Date.now(),
}),
);
const { result } = renderHook(() => useRefreshGitHubIssuesPRs(), {
wrapper: createWrapper(),
});
// Call the refresh function
result.current();
// Check that localStorage was cleared
expect(localStorage.getItem("github-issues-prs-cache")).toBeNull();
});
});

View File

@ -0,0 +1,264 @@
import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Routes, Route } from "react-router";
import GitHubIssuesPRsPage from "#/routes/github-issues-prs";
import GitHubIssuesPRsService from "#/api/github-service/github-issues-prs.api";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { useShouldShowUserFeatures } from "#/hooks/use-should-show-user-features";
// Mock the services
vi.mock("#/api/github-service/github-issues-prs.api", () => ({
default: {
getGitHubItems: vi.fn(),
buildItemUrl: vi.fn((provider, repo, number, type) => {
if (provider === "github") {
return `https://github.com/${repo}/${type === "issue" ? "issues" : "pull"}/${number}`;
}
return "";
}),
},
}));
vi.mock("#/api/conversation-service/conversation-service.api", () => ({
default: {
searchConversations: vi.fn(),
createConversation: vi.fn(),
},
}));
vi.mock("#/hooks/use-should-show-user-features", () => ({
useShouldShowUserFeatures: vi.fn(),
}));
// Mock react-i18next to return the key as the translation
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: "en" },
}),
}));
const mockGetGitHubItems = vi.mocked(GitHubIssuesPRsService.getGitHubItems);
const mockSearchConversations = vi.mocked(
ConversationService.searchConversations,
);
const mockUseShouldShowUserFeatures = vi.mocked(useShouldShowUserFeatures);
const renderGitHubIssuesPRsPage = () =>
render(
<QueryClientProvider client={new QueryClient()}>
<MemoryRouter initialEntries={["/github-issues-prs"]}>
<Routes>
<Route path="/github-issues-prs" element={<GitHubIssuesPRsPage />} />
<Route
path="/conversations/:conversationId"
element={<div data-testid="conversation-screen" />}
/>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
const MOCK_GITHUB_ITEMS = [
{
git_provider: "github" as const,
item_type: "issue" as const,
status: "OPEN_ISSUE" as const,
repo: "test/repo",
number: 1,
title: "Test Issue",
author: "testuser",
assignees: ["testuser"],
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
url: "https://github.com/test/repo/issues/1",
},
{
git_provider: "github" as const,
item_type: "pr" as const,
status: "OPEN_PR" as const,
repo: "test/repo",
number: 2,
title: "Test PR",
author: "testuser",
assignees: [],
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
url: "https://github.com/test/repo/pull/2",
},
];
describe("GitHubIssuesPRsPage", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockUseShouldShowUserFeatures.mockReturnValue(true);
mockGetGitHubItems.mockResolvedValue({
items: MOCK_GITHUB_ITEMS,
cached_at: new Date().toISOString(),
});
mockSearchConversations.mockResolvedValue([]);
});
it("should render the page title", async () => {
renderGitHubIssuesPRsPage();
await waitFor(() => {
expect(screen.getByText("GITHUB_ISSUES_PRS$TITLE")).toBeInTheDocument();
});
});
it("should render the view type selector", async () => {
renderGitHubIssuesPRsPage();
await waitFor(() => {
// The view label has a colon after it
expect(screen.getByText("GITHUB_ISSUES_PRS$VIEW:")).toBeInTheDocument();
expect(screen.getByRole("combobox")).toBeInTheDocument();
});
});
it("should render filter checkboxes", async () => {
renderGitHubIssuesPRsPage();
await waitFor(() => {
expect(
screen.getByText("GITHUB_ISSUES_PRS$ASSIGNED_TO_ME"),
).toBeInTheDocument();
expect(
screen.getByText("GITHUB_ISSUES_PRS$AUTHORED_BY_ME"),
).toBeInTheDocument();
});
});
it("should render the refresh button", async () => {
renderGitHubIssuesPRsPage();
await waitFor(() => {
expect(screen.getByText("GITHUB_ISSUES_PRS$REFRESH")).toBeInTheDocument();
});
});
it("should display GitHub items when loaded", async () => {
renderGitHubIssuesPRsPage();
await waitFor(() => {
expect(screen.getByText("Test Issue")).toBeInTheDocument();
expect(screen.getByText("Test PR")).toBeInTheDocument();
});
});
it("should display item status badges", async () => {
renderGitHubIssuesPRsPage();
await waitFor(() => {
expect(
screen.getByText("GITHUB_ISSUES_PRS$OPEN_ISSUE"),
).toBeInTheDocument();
expect(screen.getByText("GITHUB_ISSUES_PRS$OPEN_PR")).toBeInTheDocument();
});
});
it("should display Start Session buttons for items without related conversations", async () => {
renderGitHubIssuesPRsPage();
await waitFor(() => {
const startButtons = screen.getAllByText(
"GITHUB_ISSUES_PRS$START_SESSION",
);
expect(startButtons.length).toBe(2);
});
});
it("should filter items when view type is changed to issues", async () => {
renderGitHubIssuesPRsPage();
await waitFor(() => {
expect(screen.getByText("Test Issue")).toBeInTheDocument();
expect(screen.getByText("Test PR")).toBeInTheDocument();
});
// Change view type to issues
const select = screen.getByRole("combobox");
await userEvent.selectOptions(select, "issues");
await waitFor(() => {
expect(screen.getByText("Test Issue")).toBeInTheDocument();
expect(screen.queryByText("Test PR")).not.toBeInTheDocument();
});
});
it("should filter items when view type is changed to PRs", async () => {
renderGitHubIssuesPRsPage();
await waitFor(() => {
expect(screen.getByText("Test Issue")).toBeInTheDocument();
expect(screen.getByText("Test PR")).toBeInTheDocument();
});
// Change view type to PRs
const select = screen.getByRole("combobox");
await userEvent.selectOptions(select, "prs");
await waitFor(() => {
expect(screen.queryByText("Test Issue")).not.toBeInTheDocument();
expect(screen.getByText("Test PR")).toBeInTheDocument();
});
});
it("should display Resume Session button when a related conversation exists", async () => {
mockSearchConversations.mockResolvedValue([
{
conversation_id: "conv-1",
title: "Working on #1",
selected_repository: "test/repo",
selected_branch: "main",
git_provider: "github",
pr_number: [1],
created_at: "2024-01-01T00:00:00Z",
last_updated_at: "2024-01-01T00:00:00Z",
status: "RUNNING",
runtime_status: null,
url: null,
session_api_key: null,
},
]);
renderGitHubIssuesPRsPage();
await waitFor(() => {
expect(
screen.getByText("GITHUB_ISSUES_PRS$RESUME_SESSION"),
).toBeInTheDocument();
});
});
it("should show empty state when no items are found", async () => {
mockGetGitHubItems.mockResolvedValue({
items: [],
cached_at: new Date().toISOString(),
});
renderGitHubIssuesPRsPage();
await waitFor(() => {
expect(
screen.getByText("GITHUB_ISSUES_PRS$NO_ITEMS"),
).toBeInTheDocument();
});
});
it("should display View on GitHub links", async () => {
renderGitHubIssuesPRsPage();
await waitFor(() => {
const viewLinks = screen.getAllByText("GITHUB_ISSUES_PRS$VIEW_ON_GITHUB");
expect(viewLinks.length).toBe(2);
});
});
});

View File

@ -0,0 +1,76 @@
import { describe, it, expect } from "vitest";
import GitHubIssuesPRsService from "../../src/api/github-service/github-issues-prs.api";
describe("GitHubIssuesPRsService", () => {
describe("buildItemUrl", () => {
it("should build correct GitHub issue URL", () => {
const url = GitHubIssuesPRsService.buildItemUrl(
"github",
"owner/repo",
123,
"issue",
);
expect(url).toBe("https://github.com/owner/repo/issues/123");
});
it("should build correct GitHub PR URL", () => {
const url = GitHubIssuesPRsService.buildItemUrl(
"github",
"owner/repo",
456,
"pr",
);
expect(url).toBe("https://github.com/owner/repo/pull/456");
});
it("should build correct GitLab issue URL", () => {
const url = GitHubIssuesPRsService.buildItemUrl(
"gitlab",
"owner/repo",
123,
"issue",
);
expect(url).toBe("https://gitlab.com/owner/repo/-/issues/123");
});
it("should build correct GitLab MR URL", () => {
const url = GitHubIssuesPRsService.buildItemUrl(
"gitlab",
"owner/repo",
456,
"pr",
);
expect(url).toBe("https://gitlab.com/owner/repo/-/merge_requests/456");
});
it("should build correct Bitbucket issue URL", () => {
const url = GitHubIssuesPRsService.buildItemUrl(
"bitbucket",
"owner/repo",
123,
"issue",
);
expect(url).toBe("https://bitbucket.org/owner/repo/issues/123");
});
it("should build correct Bitbucket PR URL", () => {
const url = GitHubIssuesPRsService.buildItemUrl(
"bitbucket",
"owner/repo",
456,
"pr",
);
expect(url).toBe("https://bitbucket.org/owner/repo/pull-requests/456");
});
it("should return empty string for unknown provider", () => {
const url = GitHubIssuesPRsService.buildItemUrl(
"unknown" as any,
"owner/repo",
123,
"issue",
);
expect(url).toBe("");
});
});
});

View File

@ -0,0 +1,124 @@
import { openHands } from "../open-hands-axios";
import { Provider } from "#/types/settings";
export type GitHubItemType = "issue" | "pr";
export type GitHubItemStatus =
| "MERGE_CONFLICTS"
| "FAILING_CHECKS"
| "UNRESOLVED_COMMENTS"
| "OPEN_ISSUE"
| "OPEN_PR";
export interface GitHubItem {
git_provider: Provider;
item_type: GitHubItemType;
status: GitHubItemStatus;
repo: string;
number: number;
title: string;
author: string;
assignees: string[];
created_at: string;
updated_at: string;
url: string;
}
export interface GitHubItemsFilter {
itemType?: "issues" | "prs" | "all";
assignedToMe?: boolean;
authoredByMe?: boolean;
}
export interface GitHubItemsResponse {
items: GitHubItem[];
cached_at?: string;
}
/**
* GitHub Issues/PRs Service - Handles fetching GitHub issues and pull requests
*/
class GitHubIssuesPRsService {
/**
* Get GitHub issues and PRs for the authenticated user
* This uses the existing suggested-tasks endpoint and transforms the data
*/
static async getGitHubItems(
filter?: GitHubItemsFilter,
): Promise<GitHubItemsResponse> {
const { data } = await openHands.get<
Array<{
git_provider: Provider;
task_type: GitHubItemStatus;
repo: string;
issue_number: number;
title: string;
}>
>("/api/user/suggested-tasks");
// Transform the suggested tasks into GitHubItems
const items: GitHubItem[] = data.map((task) => ({
git_provider: task.git_provider,
item_type: task.task_type === "OPEN_ISSUE" ? "issue" : "pr",
status: task.task_type,
repo: task.repo,
number: task.issue_number,
title: task.title,
author: "", // Not available from suggested-tasks endpoint
assignees: [], // Not available from suggested-tasks endpoint
created_at: new Date().toISOString(), // Not available from suggested-tasks endpoint
updated_at: new Date().toISOString(), // Not available from suggested-tasks endpoint
url: GitHubIssuesPRsService.buildItemUrl(
task.git_provider,
task.repo,
task.issue_number,
task.task_type === "OPEN_ISSUE" ? "issue" : "pr",
),
}));
// Apply filters
let filteredItems = items;
if (filter?.itemType === "issues") {
filteredItems = filteredItems.filter(
(item) => item.item_type === "issue",
);
} else if (filter?.itemType === "prs") {
filteredItems = filteredItems.filter((item) => item.item_type === "pr");
}
// Note: assignedToMe and authoredByMe filters would require additional API data
// For now, the suggested-tasks endpoint already returns:
// - PRs authored by the user
// - Issues assigned to the user
// So these filters are implicitly applied by the backend
return {
items: filteredItems,
cached_at: new Date().toISOString(),
};
}
/**
* Build the URL for a GitHub item
*/
static buildItemUrl(
provider: Provider,
repo: string,
number: number,
itemType: GitHubItemType,
): string {
if (provider === "github") {
return `https://github.com/${repo}/${itemType === "issue" ? "issues" : "pull"}/${number}`;
}
if (provider === "gitlab") {
return `https://gitlab.com/${repo}/-/${itemType === "issue" ? "issues" : "merge_requests"}/${number}`;
}
if (provider === "bitbucket") {
return `https://bitbucket.org/${repo}/${itemType === "issue" ? "issues" : "pull-requests"}/${number}`;
}
return "";
}
}
export default GitHubIssuesPRsService;

View File

@ -13,6 +13,7 @@ import { useLogout } from "#/hooks/mutation/use-logout";
import { useConfig } from "#/hooks/query/use-config";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { MicroagentManagementButton } from "#/components/shared/buttons/microagent-management-button";
import { GitHubIssuesPRsButton } from "#/components/shared/buttons/github-issues-prs-button";
import { cn } from "#/utils/utils";
export function Sidebar() {
@ -85,6 +86,9 @@ export function Sidebar() {
<MicroagentManagementButton
disabled={settings?.email_verified === false}
/>
<GitHubIssuesPRsButton
disabled={settings?.email_verified === false}
/>
</div>
<div className="flex flex-row md:flex-col md:items-center gap-[26px]">

View File

@ -0,0 +1,28 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { TooltipButton } from "./tooltip-button";
import PRIcon from "#/icons/u-pr.svg?react";
interface GitHubIssuesPRsButtonProps {
disabled?: boolean;
}
export function GitHubIssuesPRsButton({
disabled = false,
}: GitHubIssuesPRsButtonProps) {
const { t } = useTranslation();
const tooltip = t(I18nKey.SIDEBAR$GITHUB_ISSUES_PRS);
return (
<TooltipButton
tooltip={tooltip}
ariaLabel={tooltip}
navLinkTo="/github-issues-prs"
testId="github-issues-prs-button"
disabled={disabled}
>
<PRIcon width={28} height={28} />
</TooltipButton>
);
}

View File

@ -0,0 +1,125 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import React from "react";
import GitHubIssuesPRsService, {
GitHubItemsFilter,
GitHubItemsResponse,
} from "#/api/github-service/github-issues-prs.api";
import { useShouldShowUserFeatures } from "../use-should-show-user-features";
const CACHE_KEY = "github-issues-prs-cache";
const CACHE_DURATION_MS = 60 * 1000; // 1 minute
interface CachedData {
data: GitHubItemsResponse;
timestamp: number;
}
/**
* Get cached data from localStorage
*/
function getCachedData(): CachedData | null {
try {
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
const parsed = JSON.parse(cached) as CachedData;
return parsed;
}
} catch {
// Ignore parse errors
}
return null;
}
/**
* Save data to localStorage cache
*/
function setCachedData(data: GitHubItemsResponse): void {
try {
const cacheEntry: CachedData = {
data,
timestamp: Date.now(),
};
localStorage.setItem(CACHE_KEY, JSON.stringify(cacheEntry));
} catch {
// Ignore storage errors
}
}
/**
* Check if cached data is still valid
*/
function isCacheValid(cached: CachedData | null): boolean {
if (!cached) return false;
return Date.now() - cached.timestamp < CACHE_DURATION_MS;
}
/**
* Hook to fetch GitHub issues and PRs with local storage caching
*/
export const useGitHubIssuesPRs = (filter?: GitHubItemsFilter) => {
const shouldShowUserFeatures = useShouldShowUserFeatures();
const queryClient = useQueryClient();
// Set up auto-refresh interval
React.useEffect(() => {
if (!shouldShowUserFeatures) return undefined;
const interval = setInterval(() => {
queryClient.invalidateQueries({ queryKey: ["github-issues-prs"] });
}, CACHE_DURATION_MS);
return () => clearInterval(interval);
}, [shouldShowUserFeatures, queryClient]);
return useQuery({
queryKey: ["github-issues-prs", filter],
queryFn: async () => {
// Check localStorage cache first
const cached = getCachedData();
if (isCacheValid(cached)) {
// Return cached data but still fetch in background
return cached!.data;
}
// Fetch fresh data
const response = await GitHubIssuesPRsService.getGitHubItems(filter);
// Save to localStorage
setCachedData(response);
return response;
},
enabled: shouldShowUserFeatures,
staleTime: CACHE_DURATION_MS,
gcTime: CACHE_DURATION_MS * 5,
// Use cached data as initial data for faster loading
initialData: () => {
const cached = getCachedData();
if (cached) {
return cached.data;
}
return undefined;
},
initialDataUpdatedAt: () => {
const cached = getCachedData();
if (cached) {
return cached.timestamp;
}
return undefined;
},
});
};
/**
* Hook to manually refresh GitHub issues and PRs data
*/
export const useRefreshGitHubIssuesPRs = () => {
const queryClient = useQueryClient();
return React.useCallback(() => {
// Clear localStorage cache
localStorage.removeItem(CACHE_KEY);
// Invalidate React Query cache
queryClient.invalidateQueries({ queryKey: ["github-issues-prs"] });
}, [queryClient]);
};

View File

@ -960,4 +960,25 @@ export enum I18nKey {
OBSERVATION_MESSAGE$SKILL_READY = "OBSERVATION_MESSAGE$SKILL_READY",
CONVERSATION$SHOW_SKILLS = "CONVERSATION$SHOW_SKILLS",
SKILLS_MODAL$TITLE = "SKILLS_MODAL$TITLE",
GITHUB_ISSUES_PRS$TITLE = "GITHUB_ISSUES_PRS$TITLE",
GITHUB_ISSUES_PRS$REFRESH = "GITHUB_ISSUES_PRS$REFRESH",
GITHUB_ISSUES_PRS$VIEW = "GITHUB_ISSUES_PRS$VIEW",
GITHUB_ISSUES_PRS$ALL = "GITHUB_ISSUES_PRS$ALL",
GITHUB_ISSUES_PRS$ISSUES = "GITHUB_ISSUES_PRS$ISSUES",
GITHUB_ISSUES_PRS$PRS = "GITHUB_ISSUES_PRS$PRS",
GITHUB_ISSUES_PRS$ASSIGNED_TO_ME = "GITHUB_ISSUES_PRS$ASSIGNED_TO_ME",
GITHUB_ISSUES_PRS$AUTHORED_BY_ME = "GITHUB_ISSUES_PRS$AUTHORED_BY_ME",
GITHUB_ISSUES_PRS$LAST_UPDATED = "GITHUB_ISSUES_PRS$LAST_UPDATED",
GITHUB_ISSUES_PRS$ERROR_LOADING = "GITHUB_ISSUES_PRS$ERROR_LOADING",
GITHUB_ISSUES_PRS$TRY_AGAIN = "GITHUB_ISSUES_PRS$TRY_AGAIN",
GITHUB_ISSUES_PRS$NO_ITEMS = "GITHUB_ISSUES_PRS$NO_ITEMS",
GITHUB_ISSUES_PRS$MERGE_CONFLICTS = "GITHUB_ISSUES_PRS$MERGE_CONFLICTS",
GITHUB_ISSUES_PRS$FAILING_CHECKS = "GITHUB_ISSUES_PRS$FAILING_CHECKS",
GITHUB_ISSUES_PRS$UNRESOLVED_COMMENTS = "GITHUB_ISSUES_PRS$UNRESOLVED_COMMENTS",
GITHUB_ISSUES_PRS$OPEN_ISSUE = "GITHUB_ISSUES_PRS$OPEN_ISSUE",
GITHUB_ISSUES_PRS$OPEN_PR = "GITHUB_ISSUES_PRS$OPEN_PR",
GITHUB_ISSUES_PRS$VIEW_ON_GITHUB = "GITHUB_ISSUES_PRS$VIEW_ON_GITHUB",
GITHUB_ISSUES_PRS$START_SESSION = "GITHUB_ISSUES_PRS$START_SESSION",
GITHUB_ISSUES_PRS$RESUME_SESSION = "GITHUB_ISSUES_PRS$RESUME_SESSION",
SIDEBAR$GITHUB_ISSUES_PRS = "SIDEBAR$GITHUB_ISSUES_PRS",
}

View File

@ -15358,5 +15358,341 @@
"es": "Habilidades disponibles",
"tr": "Kullanılabilir yetenekler",
"uk": "Доступні навички"
},
"GITHUB_ISSUES_PRS$TITLE": {
"en": "GitHub Issues & Pull Requests",
"ja": "GitHub Issues & Pull Requests",
"zh-CN": "GitHub Issues & Pull Requests",
"zh-TW": "GitHub Issues & Pull Requests",
"ko-KR": "GitHub Issues & Pull Requests",
"no": "GitHub Issues & Pull Requests",
"it": "GitHub Issues & Pull Requests",
"pt": "GitHub Issues & Pull Requests",
"es": "GitHub Issues & Pull Requests",
"ar": "GitHub Issues & Pull Requests",
"fr": "GitHub Issues & Pull Requests",
"tr": "GitHub Issues & Pull Requests",
"de": "GitHub Issues & Pull Requests",
"uk": "GitHub Issues & Pull Requests"
},
"GITHUB_ISSUES_PRS$REFRESH": {
"en": "Refresh",
"ja": "更新",
"zh-CN": "刷新",
"zh-TW": "重新整理",
"ko-KR": "새로고침",
"no": "Oppdater",
"it": "Aggiorna",
"pt": "Atualizar",
"es": "Actualizar",
"ar": "تحديث",
"fr": "Actualiser",
"tr": "Yenile",
"de": "Aktualisieren",
"uk": "Оновити"
},
"GITHUB_ISSUES_PRS$VIEW": {
"en": "View",
"ja": "表示",
"zh-CN": "查看",
"zh-TW": "檢視",
"ko-KR": "보기",
"no": "Vis",
"it": "Visualizza",
"pt": "Ver",
"es": "Ver",
"ar": "عرض",
"fr": "Voir",
"tr": "Görüntüle",
"de": "Ansicht",
"uk": "Перегляд"
},
"GITHUB_ISSUES_PRS$ALL": {
"en": "All",
"ja": "すべて",
"zh-CN": "全部",
"zh-TW": "全部",
"ko-KR": "전체",
"no": "Alle",
"it": "Tutti",
"pt": "Todos",
"es": "Todos",
"ar": "الكل",
"fr": "Tous",
"tr": "Tümü",
"de": "Alle",
"uk": "Всі"
},
"GITHUB_ISSUES_PRS$ISSUES": {
"en": "Issues",
"ja": "Issues",
"zh-CN": "Issues",
"zh-TW": "Issues",
"ko-KR": "Issues",
"no": "Issues",
"it": "Issues",
"pt": "Issues",
"es": "Issues",
"ar": "Issues",
"fr": "Issues",
"tr": "Issues",
"de": "Issues",
"uk": "Issues"
},
"GITHUB_ISSUES_PRS$PRS": {
"en": "Pull Requests",
"ja": "Pull Requests",
"zh-CN": "Pull Requests",
"zh-TW": "Pull Requests",
"ko-KR": "Pull Requests",
"no": "Pull Requests",
"it": "Pull Requests",
"pt": "Pull Requests",
"es": "Pull Requests",
"ar": "Pull Requests",
"fr": "Pull Requests",
"tr": "Pull Requests",
"de": "Pull Requests",
"uk": "Pull Requests"
},
"GITHUB_ISSUES_PRS$ASSIGNED_TO_ME": {
"en": "Assigned to me",
"ja": "自分に割り当て",
"zh-CN": "分配给我",
"zh-TW": "指派給我",
"ko-KR": "나에게 할당됨",
"no": "Tildelt meg",
"it": "Assegnato a me",
"pt": "Atribuído a mim",
"es": "Asignado a mí",
"ar": "مُعيَّن لي",
"fr": "Assigné à moi",
"tr": "Bana atanan",
"de": "Mir zugewiesen",
"uk": "Призначено мені"
},
"GITHUB_ISSUES_PRS$AUTHORED_BY_ME": {
"en": "Authored by me",
"ja": "自分が作成",
"zh-CN": "我创建的",
"zh-TW": "我建立的",
"ko-KR": "내가 작성함",
"no": "Opprettet av meg",
"it": "Creato da me",
"pt": "Criado por mim",
"es": "Creado por mí",
"ar": "من إنشائي",
"fr": "Créé par moi",
"tr": "Benim tarafımdan oluşturulan",
"de": "Von mir erstellt",
"uk": "Створено мною"
},
"GITHUB_ISSUES_PRS$LAST_UPDATED": {
"en": "Last updated",
"ja": "最終更新",
"zh-CN": "最后更新",
"zh-TW": "最後更新",
"ko-KR": "마지막 업데이트",
"no": "Sist oppdatert",
"it": "Ultimo aggiornamento",
"pt": "Última atualização",
"es": "Última actualización",
"ar": "آخر تحديث",
"fr": "Dernière mise à jour",
"tr": "Son güncelleme",
"de": "Zuletzt aktualisiert",
"uk": "Останнє оновлення"
},
"GITHUB_ISSUES_PRS$ERROR_LOADING": {
"en": "Failed to load GitHub items",
"ja": "GitHubアイテムの読み込みに失敗しました",
"zh-CN": "加载GitHub项目失败",
"zh-TW": "載入GitHub項目失敗",
"ko-KR": "GitHub 항목 로드 실패",
"no": "Kunne ikke laste GitHub-elementer",
"it": "Impossibile caricare gli elementi GitHub",
"pt": "Falha ao carregar itens do GitHub",
"es": "Error al cargar elementos de GitHub",
"ar": "فشل في تحميل عناصر GitHub",
"fr": "Échec du chargement des éléments GitHub",
"tr": "GitHub öğeleri yüklenemedi",
"de": "GitHub-Elemente konnten nicht geladen werden",
"uk": "Не вдалося завантажити елементи GitHub"
},
"GITHUB_ISSUES_PRS$TRY_AGAIN": {
"en": "Try Again",
"ja": "再試行",
"zh-CN": "重试",
"zh-TW": "重試",
"ko-KR": "다시 시도",
"no": "Prøv igjen",
"it": "Riprova",
"pt": "Tentar novamente",
"es": "Intentar de nuevo",
"ar": "حاول مرة أخرى",
"fr": "Réessayer",
"tr": "Tekrar dene",
"de": "Erneut versuchen",
"uk": "Спробувати знову"
},
"GITHUB_ISSUES_PRS$NO_ITEMS": {
"en": "No issues or pull requests found",
"ja": "IssueまたはPull Requestが見つかりません",
"zh-CN": "未找到Issue或Pull Request",
"zh-TW": "未找到Issue或Pull Request",
"ko-KR": "Issue 또는 Pull Request를 찾을 수 없습니다",
"no": "Ingen issues eller pull requests funnet",
"it": "Nessun issue o pull request trovato",
"pt": "Nenhum issue ou pull request encontrado",
"es": "No se encontraron issues ni pull requests",
"ar": "لم يتم العثور على issues أو pull requests",
"fr": "Aucun issue ou pull request trouvé",
"tr": "Issue veya pull request bulunamadı",
"de": "Keine Issues oder Pull Requests gefunden",
"uk": "Issues або Pull Requests не знайдено"
},
"GITHUB_ISSUES_PRS$MERGE_CONFLICTS": {
"en": "Merge Conflicts",
"ja": "マージコンフリクト",
"zh-CN": "合并冲突",
"zh-TW": "合併衝突",
"ko-KR": "병합 충돌",
"no": "Flettekonflikter",
"it": "Conflitti di merge",
"pt": "Conflitos de merge",
"es": "Conflictos de merge",
"ar": "تعارضات الدمج",
"fr": "Conflits de fusion",
"tr": "Birleştirme çakışmaları",
"de": "Merge-Konflikte",
"uk": "Конфлікти злиття"
},
"GITHUB_ISSUES_PRS$FAILING_CHECKS": {
"en": "Failing Checks",
"ja": "失敗したチェック",
"zh-CN": "检查失败",
"zh-TW": "檢查失敗",
"ko-KR": "실패한 검사",
"no": "Mislykkede sjekker",
"it": "Controlli falliti",
"pt": "Verificações falhando",
"es": "Verificaciones fallidas",
"ar": "فحوصات فاشلة",
"fr": "Vérifications échouées",
"tr": "Başarısız kontroller",
"de": "Fehlgeschlagene Prüfungen",
"uk": "Невдалі перевірки"
},
"GITHUB_ISSUES_PRS$UNRESOLVED_COMMENTS": {
"en": "Unresolved Comments",
"ja": "未解決のコメント",
"zh-CN": "未解决的评论",
"zh-TW": "未解決的評論",
"ko-KR": "해결되지 않은 댓글",
"no": "Uløste kommentarer",
"it": "Commenti non risolti",
"pt": "Comentários não resolvidos",
"es": "Comentarios sin resolver",
"ar": "تعليقات غير محلولة",
"fr": "Commentaires non résolus",
"tr": "Çözülmemiş yorumlar",
"de": "Ungelöste Kommentare",
"uk": "Невирішені коментарі"
},
"GITHUB_ISSUES_PRS$OPEN_ISSUE": {
"en": "Open Issue",
"ja": "オープンIssue",
"zh-CN": "开放Issue",
"zh-TW": "開放Issue",
"ko-KR": "열린 Issue",
"no": "Åpen issue",
"it": "Issue aperto",
"pt": "Issue aberto",
"es": "Issue abierto",
"ar": "Issue مفتوح",
"fr": "Issue ouvert",
"tr": "Açık issue",
"de": "Offenes Issue",
"uk": "Відкритий Issue"
},
"GITHUB_ISSUES_PRS$OPEN_PR": {
"en": "Open PR",
"ja": "オープンPR",
"zh-CN": "开放PR",
"zh-TW": "開放PR",
"ko-KR": "열린 PR",
"no": "Åpen PR",
"it": "PR aperta",
"pt": "PR aberto",
"es": "PR abierto",
"ar": "PR مفتوح",
"fr": "PR ouverte",
"tr": "Açık PR",
"de": "Offener PR",
"uk": "Відкритий PR"
},
"GITHUB_ISSUES_PRS$VIEW_ON_GITHUB": {
"en": "View on GitHub",
"ja": "GitHubで表示",
"zh-CN": "在GitHub上查看",
"zh-TW": "在GitHub上檢視",
"ko-KR": "GitHub에서 보기",
"no": "Vis på GitHub",
"it": "Visualizza su GitHub",
"pt": "Ver no GitHub",
"es": "Ver en GitHub",
"ar": "عرض على GitHub",
"fr": "Voir sur GitHub",
"tr": "GitHub'da görüntüle",
"de": "Auf GitHub ansehen",
"uk": "Переглянути на GitHub"
},
"GITHUB_ISSUES_PRS$START_SESSION": {
"en": "Start Session",
"ja": "セッションを開始",
"zh-CN": "开始会话",
"zh-TW": "開始會話",
"ko-KR": "세션 시작",
"no": "Start økt",
"it": "Avvia sessione",
"pt": "Iniciar sessão",
"es": "Iniciar sesión",
"ar": "بدء الجلسة",
"fr": "Démarrer la session",
"tr": "Oturumu başlat",
"de": "Sitzung starten",
"uk": "Почати сеанс"
},
"GITHUB_ISSUES_PRS$RESUME_SESSION": {
"en": "Resume Session",
"ja": "セッションを再開",
"zh-CN": "恢复会话",
"zh-TW": "恢復會話",
"ko-KR": "세션 재개",
"no": "Gjenoppta økt",
"it": "Riprendi sessione",
"pt": "Retomar sessão",
"es": "Reanudar sesión",
"ar": "استئناف الجلسة",
"fr": "Reprendre la session",
"tr": "Oturumu devam ettir",
"de": "Sitzung fortsetzen",
"uk": "Відновити сеанс"
},
"SIDEBAR$GITHUB_ISSUES_PRS": {
"en": "Issues & PRs",
"ja": "Issues & PRs",
"zh-CN": "Issues & PRs",
"zh-TW": "Issues & PRs",
"ko-KR": "Issues & PRs",
"no": "Issues & PRs",
"it": "Issues & PRs",
"pt": "Issues & PRs",
"es": "Issues & PRs",
"ar": "Issues & PRs",
"fr": "Issues & PRs",
"tr": "Issues & PRs",
"de": "Issues & PRs",
"uk": "Issues & PRs"
}
}

View File

@ -21,6 +21,7 @@ export default [
]),
route("conversations/:conversationId", "routes/conversation.tsx"),
route("microagent-management", "routes/microagent-management.tsx"),
route("github-issues-prs", "routes/github-issues-prs.tsx"),
route("oauth/device/verify", "routes/device-verify.tsx"),
]),
] satisfies RouteConfig;

View File

@ -0,0 +1,404 @@
import React from "react";
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import {
useGitHubIssuesPRs,
useRefreshGitHubIssuesPRs,
} from "#/hooks/query/use-github-issues-prs";
import { useSearchConversations } from "#/hooks/query/use-search-conversations";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import {
GitHubItem,
GitHubItemsFilter,
} from "#/api/github-service/github-issues-prs.api";
import { Conversation } from "#/api/open-hands.types";
import { cn } from "#/utils/utils";
type ViewType = "all" | "issues" | "prs";
interface GitHubItemCardProps {
item: GitHubItem;
relatedConversation?: Conversation;
onStartSession: () => void;
onResumeSession: () => void;
isStarting: boolean;
}
function GitHubItemCard({
item,
relatedConversation,
onStartSession,
onResumeSession,
isStarting,
}: GitHubItemCardProps) {
const { t } = useTranslation();
const getStatusBadge = () => {
switch (item.status) {
case "MERGE_CONFLICTS":
return (
<span className="px-2 py-1 text-xs rounded bg-red-500/20 text-red-400">
{t(I18nKey.GITHUB_ISSUES_PRS$MERGE_CONFLICTS)}
</span>
);
case "FAILING_CHECKS":
return (
<span className="px-2 py-1 text-xs rounded bg-orange-500/20 text-orange-400">
{t(I18nKey.GITHUB_ISSUES_PRS$FAILING_CHECKS)}
</span>
);
case "UNRESOLVED_COMMENTS":
return (
<span className="px-2 py-1 text-xs rounded bg-yellow-500/20 text-yellow-400">
{t(I18nKey.GITHUB_ISSUES_PRS$UNRESOLVED_COMMENTS)}
</span>
);
case "OPEN_ISSUE":
return (
<span className="px-2 py-1 text-xs rounded bg-green-500/20 text-green-400">
{t(I18nKey.GITHUB_ISSUES_PRS$OPEN_ISSUE)}
</span>
);
case "OPEN_PR":
return (
<span className="px-2 py-1 text-xs rounded bg-blue-500/20 text-blue-400">
{t(I18nKey.GITHUB_ISSUES_PRS$OPEN_PR)}
</span>
);
default:
return null;
}
};
return (
<div className="p-4 border border-[#525252] rounded-lg bg-[#25272D] hover:bg-[#2D2F36] transition-colors">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-neutral-400">
{item.repo}#{item.number}
</span>
{getStatusBadge()}
</div>
<h3 className="text-sm font-medium text-white truncate mb-2">
{item.title}
</h3>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-400 hover:underline"
>
{t(I18nKey.GITHUB_ISSUES_PRS$VIEW_ON_GITHUB)}
</a>
</div>
<div className="flex flex-col gap-2">
{relatedConversation ? (
<button
type="button"
onClick={onResumeSession}
className="px-3 py-1.5 text-xs font-medium rounded bg-blue-600 hover:bg-blue-700 text-white transition-colors"
>
{t(I18nKey.GITHUB_ISSUES_PRS$RESUME_SESSION)}
</button>
) : (
<button
type="button"
onClick={onStartSession}
disabled={isStarting}
className={cn(
"px-3 py-1.5 text-xs font-medium rounded transition-colors",
isStarting
? "bg-neutral-600 text-neutral-400 cursor-not-allowed"
: "bg-green-600 hover:bg-green-700 text-white",
)}
>
{isStarting ? (
<LoadingSpinner size="small" />
) : (
t(I18nKey.GITHUB_ISSUES_PRS$START_SESSION)
)}
</button>
)}
</div>
</div>
</div>
);
}
function GitHubIssuesPRsPage() {
const { t } = useTranslation();
const navigate = useNavigate();
// Filter state
const [viewType, setViewType] = React.useState<ViewType>("all");
const [assignedToMe, setAssignedToMe] = React.useState(true);
const [authoredByMe, setAuthoredByMe] = React.useState(true);
// Build filter object
const filter: GitHubItemsFilter = React.useMemo(
() => ({
itemType: viewType,
assignedToMe,
authoredByMe,
}),
[viewType, assignedToMe, authoredByMe],
);
// Fetch GitHub items
const {
data: githubData,
isLoading,
error,
isFetching,
} = useGitHubIssuesPRs(filter);
const refreshData = useRefreshGitHubIssuesPRs();
// Fetch conversations to find related ones
const { data: conversations } = useSearchConversations(
undefined,
undefined,
100,
);
// Create conversation mutation
const { mutate: createConversation, isPending: isCreating } =
useCreateConversation();
// Track which item is being started
const [startingItemKey, setStartingItemKey] = React.useState<string | null>(
null,
);
// Find conversation related to a GitHub item
const findRelatedConversation = React.useCallback(
(item: GitHubItem): Conversation | undefined => {
if (!conversations) return undefined;
// Look for conversations that match the repository and have the PR number
return conversations.find((conv) => {
// Check if the conversation is for the same repository
if (conv.selected_repository !== item.repo) return false;
// Check if the conversation has the same PR/issue number in metadata
if (conv.pr_number?.includes(item.number)) {
return true;
}
// Check if the conversation title contains the issue/PR number
if (conv.title.includes(`#${item.number}`)) {
return true;
}
return false;
});
},
[conversations],
);
// Handle starting a new session
const handleStartSession = React.useCallback(
(item: GitHubItem) => {
const itemKey = `${item.repo}-${item.number}`;
setStartingItemKey(itemKey);
// Build the initial message based on item type
let initialMessage: string;
if (item.item_type === "issue") {
initialMessage = `Please help me resolve issue #${item.number} in the ${item.repo} repository.
First, understand the issue context by reading the issue description and any comments. Then, work on resolving the issue. If you successfully resolve it, please open a draft PR with the fix.
Issue: ${item.url}`;
} else {
initialMessage = `Please help me with PR #${item.number} in the ${item.repo} repository.
First, read the PR description, comments, and check the CI results. Then, address any issues found:
- Fix failing CI checks
- Resolve merge conflicts if any
- Address review comments
PR: ${item.url}`;
}
createConversation(
{
query: initialMessage,
repository: {
name: item.repo,
gitProvider: item.git_provider,
},
},
{
onSuccess: (response) => {
setStartingItemKey(null);
navigate(`/conversations/${response.conversation_id}`);
},
onError: () => {
setStartingItemKey(null);
},
},
);
},
[createConversation, navigate],
);
// Handle resuming an existing session
const handleResumeSession = React.useCallback(
(conversation: Conversation) => {
navigate(`/conversations/${conversation.conversation_id}`);
},
[navigate],
);
// Filter items based on view type
const filteredItems = React.useMemo(() => {
if (!githubData?.items) return [];
let { items } = githubData;
if (viewType === "issues") {
items = items.filter((item) => item.item_type === "issue");
} else if (viewType === "prs") {
items = items.filter((item) => item.item_type === "pr");
}
return items;
}, [githubData?.items, viewType]);
return (
<div className="h-full flex flex-col bg-transparent overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-[#525252]">
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-semibold text-white">
{t(I18nKey.GITHUB_ISSUES_PRS$TITLE)}
</h1>
<button
type="button"
onClick={refreshData}
disabled={isFetching}
className={cn(
"px-3 py-1.5 text-sm font-medium rounded transition-colors",
isFetching
? "bg-neutral-600 text-neutral-400 cursor-not-allowed"
: "bg-neutral-700 hover:bg-neutral-600 text-white",
)}
>
{isFetching ? (
<LoadingSpinner size="small" />
) : (
t(I18nKey.GITHUB_ISSUES_PRS$REFRESH)
)}
</button>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-4">
{/* View type selector */}
<div className="flex items-center gap-2">
<span className="text-sm text-neutral-400">
{t(I18nKey.GITHUB_ISSUES_PRS$VIEW)}:
</span>
<select
value={viewType}
onChange={(e) => setViewType(e.target.value as ViewType)}
className="px-3 py-1.5 text-sm rounded bg-[#25272D] border border-[#525252] text-white focus:outline-none focus:border-blue-500"
>
<option value="all">{t(I18nKey.GITHUB_ISSUES_PRS$ALL)}</option>
<option value="issues">
{t(I18nKey.GITHUB_ISSUES_PRS$ISSUES)}
</option>
<option value="prs">{t(I18nKey.GITHUB_ISSUES_PRS$PRS)}</option>
</select>
</div>
{/* Checkboxes */}
<label className="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input
type="checkbox"
checked={assignedToMe}
onChange={(e) => setAssignedToMe(e.target.checked)}
className="w-4 h-4 rounded border-[#525252] bg-[#25272D] text-blue-500 focus:ring-blue-500 focus:ring-offset-0"
/>
{t(I18nKey.GITHUB_ISSUES_PRS$ASSIGNED_TO_ME)}
</label>
<label className="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input
type="checkbox"
checked={authoredByMe}
onChange={(e) => setAuthoredByMe(e.target.checked)}
className="w-4 h-4 rounded border-[#525252] bg-[#25272D] text-blue-500 focus:ring-blue-500 focus:ring-offset-0"
/>
{t(I18nKey.GITHUB_ISSUES_PRS$AUTHORED_BY_ME)}
</label>
</div>
{/* Cache info */}
{githubData?.cached_at && (
<div className="mt-2 text-xs text-neutral-500">
{t(I18nKey.GITHUB_ISSUES_PRS$LAST_UPDATED)}:{" "}
{new Date(githubData.cached_at).toLocaleTimeString()}
</div>
)}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
{isLoading && !githubData && (
<div className="flex items-center justify-center h-full">
<LoadingSpinner size="large" />
</div>
)}
{!isLoading && error && (
<div className="flex flex-col items-center justify-center h-full text-center">
<p className="text-red-400 mb-2">
{t(I18nKey.GITHUB_ISSUES_PRS$ERROR_LOADING)}
</p>
<button
type="button"
onClick={refreshData}
className="px-4 py-2 text-sm font-medium rounded bg-blue-600 hover:bg-blue-700 text-white transition-colors"
>
{t(I18nKey.GITHUB_ISSUES_PRS$TRY_AGAIN)}
</button>
</div>
)}
{!isLoading && !error && filteredItems.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-center">
<p className="text-neutral-400">
{t(I18nKey.GITHUB_ISSUES_PRS$NO_ITEMS)}
</p>
</div>
)}
{!isLoading && !error && filteredItems.length > 0 && (
<div className="grid gap-4 max-w-4xl mx-auto">
{filteredItems.map((item) => {
const itemKey = `${item.repo}-${item.number}`;
const relatedConversation = findRelatedConversation(item);
return (
<GitHubItemCard
key={itemKey}
item={item}
relatedConversation={relatedConversation}
onStartSession={() => handleStartSession(item)}
onResumeSession={() =>
relatedConversation &&
handleResumeSession(relatedConversation)
}
isStarting={isCreating && startingItemKey === itemKey}
/>
);
})}
</div>
)}
</div>
</div>
);
}
export default GitHubIssuesPRsPage;