From b4e6b2fe43f35e0a8d87a20ce6d67d952d79c998 Mon Sep 17 00:00:00 2001 From: Andy <119136210+AndyMik90@users.noreply.github.com> Date: Wed, 4 Feb 2026 14:06:49 +0100 Subject: [PATCH] auto-claude: 182-implement-pagination-and-filtering-for-github-pr-l (#1654) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * auto-claude: subtask-1-1 - Add GITHUB_PR_LIST_MORE IPC channel constant Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-1-2 - Add listMorePRs IPC handler for pagination - Add GITHUB_PR_LIST_MORE handler that accepts cursor parameter - Update PRListResult interface to include endCursor field - Update existing GITHUB_PR_LIST handler to also return endCursor - Both handlers use GraphQL pagination with cursor-based navigation Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-1-3 - Update PRListResult type to include endCursor field Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-1-4 - Add listMorePRs method to GitHubAPI interface Add listMorePRs method for cursor-based pagination to the GitHubAPI interface and createGitHubAPI implementation in preload. Also update browser-mock.ts to include the new method for type consistency. Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-2-1 - Add sortBy option to PRFilterState interface and DEFAULT_FILTERS - Added PRSortOption type with 'newest' | 'oldest' | 'largest' options - Added sortBy field to PRFilterState interface - Set default sortBy to 'newest' in DEFAULT_FILTERS Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-2-2 - Add sorting logic to filteredPRs useMemo in usePRF * auto-claude: subtask-2-4 - Add loadMore function, endCursor state, and isLoadingMore state - Add isLoadingMore state to track pagination loading - Add endCursor state to track pagination cursor - Add loadMore function for cursor-based pagination - Update UseGitHubPRsResult interface with new properties - Store endCursor from fetchPRs API response - Reset endCursor when project changes - Batch preload review results for newly loaded PRs Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-3-1 - Add sort dropdown to PRFilterBar using FilterDropdown - Add SortDropdown component with single-select behavior for sorting PRs - Add SORT_OPTIONS constant with newest/oldest/largest options - Add onSortChange prop to PRFilterBarProps interface - Import ArrowUpDown, Clock, FileCode icons from lucide-react - Import PRSortOption type from usePRFiltering hook - Update GitHubPRs.tsx to pass setSortBy as onSortChange prop - Add i18n translations for sort labels (en/fr) Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-3-2 - Add onLoadMore and isLoadingMore props to PRList component Add pagination props to PRListProps interface: - onLoadMore: Optional callback to load more PRs when hasMore is true - isLoadingMore: Optional boolean to track loading state for pagination Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-3-3 - Replace status indicator text with Load More button - Add Load More button component to PRList when hasMore is true - Show loading spinner with "Loading..." text when isLoadingMore is true - Keep "All PRs loaded" text when all PRs are displayed - Add prReview.loadMore and prReview.loadingMore translation keys - Import Button and Loader2 components Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-3-4 - Wire up new props in GitHubPRs.tsx parent component - Add loadMore and isLoadingMore to useGitHubPRs destructuring - Pass loadMore as onLoadMore prop to PRList component - Pass isLoadingMore to PRList component for loading state - setSortBy already wired to PRFilterBar's onSortChange Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-4-1 - Add English translation keys for sortBy, sortNewest, sortOldest, sortLargest, loadMore, loadingMore Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-4-2 - Add French translation keys for sortBy, sortNewest Adds French translations for pagination and sorting UI elements: - sort.sortBy: "Trier par" - sort.sortNewest: "Plus récent" - sort.sortOldest: "Plus ancien" - sort.sortLargest: "Plus grand" - pagination.loadMore: "Charger plus" - pagination.loadingMore: "Chargement..." Co-Authored-By: Claude Opus 4.5 * Fix ruff formatting and Windows dataclass import error - Apply ruff formatting to parallel_orchestrator_reviewer.py (3 long lines) - Apply ruff formatting to pydantic_models.py (Field on single line) - Fix Windows test collection error: register module in sys.modules before exec_module so dataclass decorator can find it Co-Authored-By: Claude Opus 4.5 * Fix PR review findings: dedup mapping, race conditions, unused i18n keys - Extract shared mapGraphQLPRToData helper to eliminate duplicated PR mapping logic between listPRs and listMorePRs handlers - Add staleness checks to loadMore using generation counter and projectId ref to prevent race conditions with refresh and project switching - Reset isLoadingMore on project change to prevent stuck loading state - Remove unused common.sort.* and common.pagination.* translation keys from en/fr locale files (code uses prReview.* keys instead) - Align endCursor type to string | null in preload PRListResult - Preserve sortBy preference when clearing filters for consistency with hasActiveFilters Co-Authored-By: Claude Opus 4.5 * Fix PR review findings: dedup PR handlers, stable sort order - Extract fetchPRsFromGraphQL helper to deduplicate listPRs/listMorePRs handlers - Add secondary sort key (createdAt) to 'largest' sort for stable ordering Co-Authored-By: Claude Opus 4.5 * Fix PR review findings: deduplication and keyboard navigation - Add deduplication by PR number when appending paginated PRs to prevent duplicates if a PR shifts position between pagination requests - Add keyboard navigation to SortDropdown (ArrowUp/Down, Enter, Space, Escape) matching the pattern used in FilterDropdown for accessibility consistency Co-Authored-By: Claude Opus 4.5 * Fix follow-up review findings: pagination, sort, keyboard UX - Preserve pagination state on failure response to allow retry - Pre-compute timestamps before sorting to avoid Date object creation - Add scrollIntoView for keyboard-focused items in FilterDropdown - Focus current selection when SortDropdown opens for better keyboard UX Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Test User --- .../main/ipc-handlers/github/pr-handlers.ts | 204 +++++++++++------- .../src/preload/api/modules/github-api.ts | 7 + .../components/github-prs/GitHubPRs.tsx | 6 + .../github-prs/components/PRFilterBar.tsx | 160 +++++++++++++- .../github-prs/components/PRList.tsx | 35 ++- .../github-prs/hooks/useGitHubPRs.ts | 104 +++++++++ .../github-prs/hooks/usePRFiltering.ts | 48 ++++- .../frontend/src/renderer/lib/browser-mock.ts | 1 + apps/frontend/src/shared/constants/ipc.ts | 1 + .../src/shared/i18n/locales/en/common.json | 8 + .../src/shared/i18n/locales/fr/common.json | 8 + 11 files changed, 493 insertions(+), 89 deletions(-) diff --git a/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts index 59a8eeab..b974c717 100644 --- a/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts @@ -40,6 +40,23 @@ import { * GraphQL response type for PR list query * Note: repository can be null if the repo doesn't exist or user lacks access */ +interface GraphQLPRNode { + number: number; + title: string; + body: string | null; + state: string; + author: { login: string } | null; + headRefName: string; + baseRefName: string; + additions: number; + deletions: number; + changedFiles: number; + assignees: { nodes: Array<{ login: string }> }; + createdAt: string; + updatedAt: string; + url: string; +} + interface GraphQLPRListResponse { data: { repository: { @@ -48,28 +65,37 @@ interface GraphQLPRListResponse { hasNextPage: boolean; endCursor: string | null; }; - nodes: Array<{ - number: number; - title: string; - body: string | null; - state: string; - author: { login: string } | null; - headRefName: string; - baseRefName: string; - additions: number; - deletions: number; - changedFiles: number; - assignees: { nodes: Array<{ login: string }> }; - createdAt: string; - updatedAt: string; - url: string; - }>; + nodes: GraphQLPRNode[]; }; } | null; }; errors?: Array<{ message: string }>; } +/** + * Maps a GraphQL PR node to the frontend PRData format. + * Shared between listPRs and listMorePRs handlers. + */ +function mapGraphQLPRToData(pr: GraphQLPRNode): PRData { + return { + number: pr.number, + title: pr.title, + body: pr.body ?? "", + state: pr.state.toLowerCase(), + author: { login: pr.author?.login ?? "unknown" }, + headRefName: pr.headRefName, + baseRefName: pr.baseRefName, + additions: pr.additions, + deletions: pr.deletions, + changedFiles: pr.changedFiles, + assignees: pr.assignees.nodes.map((a) => ({ login: a.login })), + files: [], + createdAt: pr.createdAt, + updatedAt: pr.updatedAt, + htmlUrl: pr.url, + }; +} + /** * Make a GraphQL request to GitHub API */ @@ -487,6 +513,7 @@ export interface PRData { export interface PRListResult { prs: PRData[]; hasNextPage: boolean; // True if more PRs exist beyond the 100 limit + endCursor?: string | null; // Cursor for fetching next page (null if no more pages) } /** @@ -1347,13 +1374,75 @@ async function runPRReview( } } +/** + * Shared helper to fetch PRs via GraphQL API. + * Used by both listPRs and listMorePRs handlers to avoid code duplication. + */ +async function fetchPRsFromGraphQL( + config: { token: string; repo: string }, + cursor: string | null, + debugContext: string +): Promise { + // Parse owner/repo from config - must be exactly "owner/repo" format + const normalizedRepo = normalizeRepoReference(config.repo); + const repoParts = normalizedRepo.split("/"); + if (repoParts.length !== 2 || !repoParts[0] || !repoParts[1]) { + debugLog("Invalid repo format - expected 'owner/repo'", { + repo: config.repo, + normalized: normalizedRepo, + context: debugContext, + }); + return { prs: [], hasNextPage: false, endCursor: null }; + } + const [owner, repo] = repoParts; + + try { + // Use GraphQL API to get PRs with diff stats (REST list endpoint doesn't include them) + // Fetches up to 100 open PRs (GitHub GraphQL max per request) + const response = await githubGraphQL( + config.token, + LIST_PRS_QUERY, + { + owner, + repo, + first: 100, // GitHub GraphQL max is 100 + after: cursor, + } + ); + + // Handle case where repository doesn't exist or user lacks access + if (!response.data.repository) { + debugLog("Repository not found or access denied", { owner, repo, context: debugContext }); + return { prs: [], hasNextPage: false, endCursor: null }; + } + + const { nodes: prNodes, pageInfo } = response.data.repository.pullRequests; + + debugLog(`Fetched PRs via GraphQL (${debugContext})`, { + count: prNodes.length, + hasNextPage: pageInfo.hasNextPage, + endCursor: pageInfo.endCursor, + }); + return { + prs: prNodes.map(mapGraphQLPRToData), + hasNextPage: pageInfo.hasNextPage, + endCursor: pageInfo.endCursor, + }; + } catch (error) { + debugLog(`Failed to fetch PRs (${debugContext})`, { + error: error instanceof Error ? error.message : error, + }); + return { prs: [], hasNextPage: false, endCursor: null }; + } +} + /** * Register PR-related handlers */ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): void { debugLog("Registering PR handlers"); - // List open PRs - fetches up to 100 open PRs at once, returns hasNextPage from API + // List open PRs - fetches up to 100 open PRs at once, returns hasNextPage and endCursor from API ipcMain.handle( IPC_CHANNELS.GITHUB_PR_LIST, async (_, projectId: string): Promise => { @@ -1362,69 +1451,28 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v const config = getGitHubConfig(project); if (!config) { debugLog("No GitHub config found for project"); - return { prs: [], hasNextPage: false }; - } - - try { - // Parse owner/repo from config - must be exactly "owner/repo" format - const normalizedRepo = normalizeRepoReference(config.repo); - const repoParts = normalizedRepo.split("/"); - if (repoParts.length !== 2 || !repoParts[0] || !repoParts[1]) { - debugLog("Invalid repo format - expected 'owner/repo'", { repo: config.repo, normalized: normalizedRepo }); - return { prs: [], hasNextPage: false }; - } - const [owner, repo] = repoParts; - - // Use GraphQL API to get PRs with diff stats (REST list endpoint doesn't include them) - // Fetches up to 100 open PRs (GitHub GraphQL max per request) - const response = await githubGraphQL( - config.token, - LIST_PRS_QUERY, - { - owner, - repo, - first: 100, // GitHub GraphQL max is 100 - after: null, // Start from beginning - } - ); - - // Handle case where repository doesn't exist or user lacks access - if (!response.data.repository) { - debugLog("Repository not found or access denied", { owner, repo }); - return { prs: [], hasNextPage: false }; - } - - const { nodes: prNodes, pageInfo } = response.data.repository.pullRequests; - - debugLog("Fetched PRs via GraphQL", { count: prNodes.length, hasNextPage: pageInfo.hasNextPage }); - return { - prs: prNodes.map((pr) => ({ - number: pr.number, - title: pr.title, - body: pr.body ?? "", - state: pr.state.toLowerCase(), - author: { login: pr.author?.login ?? "unknown" }, - headRefName: pr.headRefName, - baseRefName: pr.baseRefName, - additions: pr.additions, - deletions: pr.deletions, - changedFiles: pr.changedFiles, - assignees: pr.assignees.nodes.map((a) => ({ login: a.login })), - files: [], - createdAt: pr.createdAt, - updatedAt: pr.updatedAt, - htmlUrl: pr.url, - })), - hasNextPage: pageInfo.hasNextPage, - }; - } catch (error) { - debugLog("Failed to fetch PRs", { - error: error instanceof Error ? error.message : error, - }); - return { prs: [], hasNextPage: false }; + return { prs: [], hasNextPage: false, endCursor: null }; } + return fetchPRsFromGraphQL(config, null, "initial"); }); - return result ?? { prs: [], hasNextPage: false }; + return result ?? { prs: [], hasNextPage: false, endCursor: null }; + } + ); + + // Load more PRs (pagination) - fetches next page of PRs using cursor + ipcMain.handle( + IPC_CHANNELS.GITHUB_PR_LIST_MORE, + async (_, projectId: string, cursor: string): Promise => { + debugLog("listMorePRs handler called", { projectId, cursor }); + const result = await withProjectOrNull(projectId, async (project) => { + const config = getGitHubConfig(project); + if (!config) { + debugLog("No GitHub config found for project"); + return { prs: [], hasNextPage: false, endCursor: null }; + } + return fetchPRsFromGraphQL(config, cursor, "pagination"); + }); + return result ?? { prs: [], hasNextPage: false, endCursor: null }; } ); diff --git a/apps/frontend/src/preload/api/modules/github-api.ts b/apps/frontend/src/preload/api/modules/github-api.ts index 1260b516..2a9ca09d 100644 --- a/apps/frontend/src/preload/api/modules/github-api.ts +++ b/apps/frontend/src/preload/api/modules/github-api.ts @@ -269,6 +269,8 @@ export interface GitHubAPI { // PR operations (fetches up to 100 open PRs at once - GitHub GraphQL limit) listPRs: (projectId: string) => Promise; + /** Load more PRs using cursor-based pagination */ + listMorePRs: (projectId: string, cursor: string) => Promise; getPR: (projectId: string, prNumber: number) => Promise; runPRReview: (projectId: string, prNumber: number) => void; cancelPRReview: (projectId: string, prNumber: number) => Promise; @@ -338,6 +340,7 @@ export interface PRData { export interface PRListResult { prs: PRData[]; hasNextPage: boolean; // True if more PRs exist beyond the 100 limit + endCursor?: string | null; // Cursor for fetching next page (null if no more pages) } /** @@ -675,6 +678,10 @@ export const createGitHubAPI = (): GitHubAPI => ({ listPRs: (projectId: string): Promise => invokeIpc(IPC_CHANNELS.GITHUB_PR_LIST, projectId), + // Load more PRs using cursor-based pagination + listMorePRs: (projectId: string, cursor: string): Promise => + invokeIpc(IPC_CHANNELS.GITHUB_PR_LIST_MORE, projectId, cursor), + getPR: (projectId: string, prNumber: number): Promise => invokeIpc(IPC_CHANNELS.GITHUB_PR_GET, projectId, prNumber), diff --git a/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx b/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx index 9baf7d69..f7d997bc 100644 --- a/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx +++ b/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx @@ -58,6 +58,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps) const { prs, isLoading, + isLoadingMore, isLoadingPRDetails, error, selectedPRNumber, @@ -78,6 +79,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps) assignPR, markReviewPosted, refresh, + loadMore, isConnected, repoFullName, getReviewStateForPR, @@ -96,6 +98,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps) setSearchQuery, setContributors, setStatuses, + setSortBy, clearFilters, hasActiveFilters, } = usePRFiltering(prs, getReviewStateForPR); @@ -226,6 +229,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps) onSearchChange={setSearchQuery} onContributorsChange={setContributors} onStatusesChange={setStatuses} + onSortChange={setSortBy} onClearFilters={clearFilters} /> } diff --git a/apps/frontend/src/renderer/components/github-prs/components/PRFilterBar.tsx b/apps/frontend/src/renderer/components/github-prs/components/PRFilterBar.tsx index 72082e14..2a6098a7 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/PRFilterBar.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/PRFilterBar.tsx @@ -4,7 +4,7 @@ * Multi-select dropdowns with visible chip selections */ -import { useState, useMemo, useRef, useCallback } from 'react'; +import { useState, useMemo, useRef, useCallback, useEffect } from 'react'; import { Search, Users, @@ -17,7 +17,10 @@ import { X, Filter, Check, - Loader2 + Loader2, + ArrowUpDown, + Clock, + FileCode } from 'lucide-react'; import { Input } from '../../ui/input'; import { Badge } from '../../ui/badge'; @@ -29,7 +32,7 @@ import { DropdownMenuTrigger, } from '../../ui/dropdown-menu'; import { useTranslation } from 'react-i18next'; -import type { PRFilterState, PRStatusFilter } from '../hooks/usePRFiltering'; +import type { PRFilterState, PRStatusFilter, PRSortOption } from '../hooks/usePRFiltering'; import { cn } from '../../../lib/utils'; interface PRFilterBarProps { @@ -39,6 +42,7 @@ interface PRFilterBarProps { onSearchChange: (query: string) => void; onContributorsChange: (contributors: string[]) => void; onStatusesChange: (statuses: PRStatusFilter[]) => void; + onSortChange: (sortBy: PRSortOption) => void; onClearFilters: () => void; } @@ -59,6 +63,17 @@ const STATUS_OPTIONS: Array<{ { value: 'ready_for_followup', labelKey: 'prReview.readyForFollowup', icon: RefreshCw, color: 'text-cyan-400', bgColor: 'bg-cyan-500/20' }, ]; +// Sort options +const SORT_OPTIONS: Array<{ + value: PRSortOption; + labelKey: string; + icon: typeof Clock; +}> = [ + { value: 'newest', labelKey: 'prReview.sort.newest', icon: Clock }, + { value: 'oldest', labelKey: 'prReview.sort.oldest', icon: Clock }, + { value: 'largest', labelKey: 'prReview.sort.largest', icon: FileCode }, +]; + /** * Modern Filter Dropdown Component */ @@ -138,6 +153,13 @@ function FilterDropdown({ } }, [filteredItems, focusedIndex, toggleItem]); + // Scroll focused item into view for keyboard navigation + useEffect(() => { + if (focusedIndex >= 0 && itemRefs.current[focusedIndex]) { + itemRefs.current[focusedIndex]?.scrollIntoView({ block: 'nearest' }); + } + }, [focusedIndex]); + return ( { setIsOpen(open); @@ -279,6 +301,127 @@ function FilterDropdown({ ); } +/** + * Single-select Sort Dropdown Component + */ +function SortDropdown({ + value, + onChange, + options, + title, +}: { + value: PRSortOption; + onChange: (value: PRSortOption) => void; + options: typeof SORT_OPTIONS; + title: string; +}) { + const { t } = useTranslation('common'); + const [isOpen, setIsOpen] = useState(false); + const [focusedIndex, setFocusedIndex] = useState(-1); + + const currentOption = options.find((opt) => opt.value === value) || options[0]; + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (options.length === 0) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setFocusedIndex((prev) => (prev < options.length - 1 ? prev + 1 : 0)); + break; + case 'ArrowUp': + e.preventDefault(); + setFocusedIndex((prev) => (prev > 0 ? prev - 1 : options.length - 1)); + break; + case 'Enter': + case ' ': + e.preventDefault(); + if (focusedIndex >= 0 && focusedIndex < options.length) { + onChange(options[focusedIndex].value); + setIsOpen(false); + } + break; + case 'Escape': + setIsOpen(false); + break; + } + }, [options, focusedIndex, onChange]); + + return ( + { + setIsOpen(open); + if (open) { + // Focus current selection on open for better keyboard UX + setFocusedIndex(options.findIndex((o) => o.value === value)); + } else { + setFocusedIndex(-1); + } + }} + > + + + + +
+
+ {title} +
+
+
+ {options.map((option, index) => { + const isSelected = value === option.value; + const isFocused = focusedIndex === index; + const Icon = option.icon; + return ( +
{ + onChange(option.value); + setIsOpen(false); + }} + > +
+ {isSelected && } +
+ + {t(option.labelKey)} +
+ ); + })} +
+
+
+ ); +} + export function PRFilterBar({ filters, contributors, @@ -286,6 +429,7 @@ export function PRFilterBar({ onSearchChange, onContributorsChange, onStatusesChange, + onSortChange, onClearFilters, }: PRFilterBarProps) { const { t } = useTranslation('common'); @@ -393,6 +537,16 @@ export function PRFilterBar({ /> + {/* Sort Dropdown */} +
+ +
+ {/* Reset All */} {hasActiveFilters && ( + ) : ( + + {t('prReview.allPRsLoaded')} + + )} )} diff --git a/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts b/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts index 2dec35bb..58c43d5c 100644 --- a/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts +++ b/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts @@ -23,6 +23,7 @@ interface UseGitHubPRsOptions { interface UseGitHubPRsResult { prs: PRData[]; isLoading: boolean; + isLoadingMore: boolean; // Loading additional PRs via pagination isLoadingPRDetails: boolean; // Loading full PR details including files error: string | null; selectedPR: PRData | null; @@ -38,6 +39,7 @@ interface UseGitHubPRsResult { hasMore: boolean; // True when 100 PRs returned (GitHub limit) - more may exist selectPR: (prNumber: number | null) => void; refresh: () => Promise; + loadMore: () => Promise; // Load next page of PRs runReview: (prNumber: number) => void; runFollowupReview: (prNumber: number) => void; checkNewCommits: (prNumber: number) => Promise; @@ -76,6 +78,8 @@ export function useGitHubPRs( const [isConnected, setIsConnected] = useState(false); const [repoFullName, setRepoFullName] = useState(null); const [hasMore, setHasMore] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [endCursor, setEndCursor] = useState(null); // Track previous isActive state to detect tab navigation const wasActiveRef = useRef(isActive); @@ -85,6 +89,10 @@ export function useGitHubPRs( const currentFetchPRNumberRef = useRef(null); // AbortController for cancelling pending checkNewCommits calls on rapid PR switching const checkNewCommitsAbortRef = useRef(null); + // Track current projectId for staleness checks in async operations + const currentProjectIdRef = useRef(projectId); + // Counter to detect stale loadMore responses after a refresh + const fetchGenerationRef = useRef(0); // Get PR review state from the global store const prReviews = usePRReviewStore((state) => state.prReviews); @@ -143,6 +151,9 @@ export function useGitHubPRs( async () => { if (!projectId) return; + // Increment generation to invalidate any in-flight loadMore requests + fetchGenerationRef.current += 1; + setIsLoading(true); setError(null); @@ -159,6 +170,8 @@ export function useGitHubPRs( if (result) { // Use hasNextPage from API to determine if more PRs exist setHasMore(result.hasNextPage); + // Store endCursor for pagination + setEndCursor(result.endCursor ?? null); setPrs(result.prs); // Batch preload review results for PRs not in store (single IPC call) @@ -223,11 +236,15 @@ export function useGitHubPRs( // Reset state and selected PR when project changes useEffect(() => { + currentProjectIdRef.current = projectId; + fetchGenerationRef.current += 1; hasLoadedRef.current = false; setHasMore(false); + setEndCursor(null); setPrs([]); setSelectedPRNumber(null); setSelectedPRDetails(null); + setIsLoadingMore(false); currentFetchPRNumberRef.current = null; // Cancel any pending checkNewCommits request if (checkNewCommitsAbortRef.current) { @@ -377,6 +394,91 @@ export function useGitHubPRs( await fetchPRs(); }, [fetchPRs]); + // Load more PRs using cursor-based pagination + const loadMore = useCallback(async () => { + if (!projectId || !endCursor || !hasMore || isLoadingMore) return; + + // Capture current state for staleness checks + const requestProjectId = projectId; + const requestGeneration = fetchGenerationRef.current; + + setIsLoadingMore(true); + setError(null); + + try { + const result = await window.electronAPI.github.listMorePRs(projectId, endCursor); + + // Discard response if project changed or a refresh happened while loading + if ( + requestProjectId !== currentProjectIdRef.current || + requestGeneration !== fetchGenerationRef.current + ) { + return; + } + + if (result) { + // Check if this is a failure response (empty result with no next page) + // In this case, preserve existing pagination state to allow retry + const isFailureResponse = result.prs.length === 0 && !result.hasNextPage && !result.endCursor; + + if (!isFailureResponse) { + // Update pagination state only on successful response + setHasMore(result.hasNextPage); + setEndCursor(result.endCursor ?? null); + + // Append new PRs to existing list, deduplicating by PR number + // (handles edge case where PR shifts position between pagination requests) + setPrs((prevPrs) => { + const existingNumbers = new Set(prevPrs.map((pr) => pr.number)); + const newPrs = result.prs.filter((pr) => !existingNumbers.has(pr.number)); + return [...prevPrs, ...newPrs]; + }); + } + + // Batch preload review results for new PRs not in store + const prsNeedingPreload = result.prs.filter((pr) => { + const existingState = getPRReviewState(requestProjectId, pr.number); + return !existingState?.result && !existingState?.isReviewing; + }); + + if (prsNeedingPreload.length > 0) { + const prNumbers = prsNeedingPreload.map((pr) => pr.number); + const batchReviews = await window.electronAPI.github.getPRReviewsBatch( + requestProjectId, + prNumbers + ); + + // Check staleness again after async batch fetch + if ( + requestProjectId !== currentProjectIdRef.current || + requestGeneration !== fetchGenerationRef.current + ) { + return; + } + + // Update store with loaded results + for (const reviewResult of Object.values(batchReviews)) { + if (reviewResult) { + usePRReviewStore.getState().setPRReviewResult(requestProjectId, reviewResult, { + preserveNewCommitsCheck: true, + }); + } + } + } + } + } catch (err) { + // Only show error if still relevant + if ( + requestProjectId === currentProjectIdRef.current && + requestGeneration === fetchGenerationRef.current + ) { + setError(err instanceof Error ? err.message : "Failed to load more PRs"); + } + } finally { + setIsLoadingMore(false); + } + }, [projectId, endCursor, hasMore, isLoadingMore, getPRReviewState]); + const runReview = useCallback( (prNumber: number) => { if (!projectId) return; @@ -567,6 +669,7 @@ export function useGitHubPRs( return { prs, isLoading, + isLoadingMore, isLoadingPRDetails, error, selectedPR, @@ -582,6 +685,7 @@ export function useGitHubPRs( hasMore, selectPR, refresh, + loadMore, runReview, runFollowupReview, checkNewCommits, diff --git a/apps/frontend/src/renderer/components/github-prs/hooks/usePRFiltering.ts b/apps/frontend/src/renderer/components/github-prs/hooks/usePRFiltering.ts index 928871f3..4ee063ee 100644 --- a/apps/frontend/src/renderer/components/github-prs/hooks/usePRFiltering.ts +++ b/apps/frontend/src/renderer/components/github-prs/hooks/usePRFiltering.ts @@ -16,10 +16,13 @@ export type PRStatusFilter = | 'ready_to_merge' | 'ready_for_followup'; +export type PRSortOption = 'newest' | 'oldest' | 'largest'; + export interface PRFilterState { searchQuery: string; contributors: string[]; statuses: PRStatusFilter[]; + sortBy: PRSortOption; } interface PRReviewInfo { @@ -32,6 +35,7 @@ const DEFAULT_FILTERS: PRFilterState = { searchQuery: '', contributors: [], statuses: [], + sortBy: 'newest', }; /** @@ -98,9 +102,9 @@ export function usePRFiltering( ); }, [prs]); - // Filter PRs based on current filters + // Filter and sort PRs based on current filters const filteredPRs = useMemo(() => { - return prs.filter(pr => { + const filtered = prs.filter(pr => { // Search filter - matches title or body if (filters.searchQuery) { const query = filters.searchQuery.toLowerCase(); @@ -142,6 +146,36 @@ export function usePRFiltering( return true; }); + + // Pre-compute timestamps to avoid creating Date objects on every comparison + const timestamps = new Map( + filtered.map((pr) => [pr.number, new Date(pr.createdAt).getTime()]) + ); + + // Sort the filtered results + return filtered.sort((a, b) => { + const aTime = timestamps.get(a.number)!; + const bTime = timestamps.get(b.number)!; + + switch (filters.sortBy) { + case 'newest': + // Sort by createdAt descending (most recent first) + return bTime - aTime; + case 'oldest': + // Sort by createdAt ascending (oldest first) + return aTime - bTime; + case 'largest': { + // Sort by total changes (additions + deletions) descending + const aChanges = (a.additions || 0) + (a.deletions || 0); + const bChanges = (b.additions || 0) + (b.deletions || 0); + if (bChanges !== aChanges) return bChanges - aChanges; + // Secondary sort by createdAt (newest first) for stable ordering + return bTime - aTime; + } + default: + return 0; + } + }); }, [prs, filters, getReviewStateForPR]); // Filter setters @@ -157,8 +191,15 @@ export function usePRFiltering( setFiltersState(prev => ({ ...prev, statuses })); }, []); + const setSortBy = useCallback((sortBy: PRSortOption) => { + setFiltersState(prev => ({ ...prev, sortBy })); + }, []); + const clearFilters = useCallback(() => { - setFiltersState(DEFAULT_FILTERS); + setFiltersState((prev) => ({ + ...DEFAULT_FILTERS, + sortBy: prev.sortBy, // Preserve sort preference when clearing filters + })); }, []); const hasActiveFilters = useMemo(() => { @@ -176,6 +217,7 @@ export function usePRFiltering( setSearchQuery, setContributors, setStatuses, + setSortBy, clearFilters, hasActiveFilters, }; diff --git a/apps/frontend/src/renderer/lib/browser-mock.ts b/apps/frontend/src/renderer/lib/browser-mock.ts index bdec853d..e5e2efea 100644 --- a/apps/frontend/src/renderer/lib/browser-mock.ts +++ b/apps/frontend/src/renderer/lib/browser-mock.ts @@ -203,6 +203,7 @@ const browserMockAPI: ElectronAPI = { onAutoFixComplete: () => () => {}, onAutoFixError: () => () => {}, listPRs: async () => ({ prs: [], hasNextPage: false }), + listMorePRs: async () => ({ prs: [], hasNextPage: false }), getPR: async () => null, runPRReview: () => {}, cancelPRReview: async () => true, diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index 03ff9098..6725a907 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -388,6 +388,7 @@ export const IPC_CHANNELS = { // GitHub PR Review operations GITHUB_PR_LIST: 'github:pr:list', + GITHUB_PR_LIST_MORE: 'github:pr:listMore', // Load more PRs (pagination) GITHUB_PR_GET: 'github:pr:get', GITHUB_PR_GET_DIFF: 'github:pr:getDiff', GITHUB_PR_REVIEW: 'github:pr:review', diff --git a/apps/frontend/src/shared/i18n/locales/en/common.json b/apps/frontend/src/shared/i18n/locales/en/common.json index b97b1f45..57b60ad2 100644 --- a/apps/frontend/src/shared/i18n/locales/en/common.json +++ b/apps/frontend/src/shared/i18n/locales/en/common.json @@ -243,6 +243,12 @@ "selectedCount": "{{count}} selected", "noResultsFound": "No results found", "reset": "Reset", + "sort": { + "label": "Sort", + "newest": "Newest", + "oldest": "Oldest", + "largest": "Largest" + }, "pullRequests": "Pull Requests", "open": "open", "selectPRToView": "Select a pull request to view details", @@ -361,6 +367,8 @@ "branchUpdateFailed": "Failed to update branch", "allPRsLoaded": "All PRs loaded", "maxPRsShown": "Showing first 100 PRs", + "loadMore": "Load More", + "loadingMore": "Loading...", "workflowsAwaitingApproval": "{{count}} Workflow Awaiting Approval", "workflowsAwaitingApproval_plural": "{{count}} Workflows Awaiting Approval", "blockedByWorkflows": "Blocked", diff --git a/apps/frontend/src/shared/i18n/locales/fr/common.json b/apps/frontend/src/shared/i18n/locales/fr/common.json index f4412451..a41527ec 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/common.json +++ b/apps/frontend/src/shared/i18n/locales/fr/common.json @@ -243,6 +243,12 @@ "selectedCount": "{{count}} sélectionné(s)", "noResultsFound": "Aucun résultat trouvé", "reset": "Réinitialiser", + "sort": { + "label": "Trier", + "newest": "Plus récent", + "oldest": "Plus ancien", + "largest": "Plus grand" + }, "pullRequests": "Pull Requests", "open": "ouvert", "selectPRToView": "Sélectionnez une pull request pour voir les détails", @@ -370,6 +376,8 @@ "branchUpdateFailed": "Échec de la mise à jour de la branche", "allPRsLoaded": "Tous les PRs chargés", "maxPRsShown": "Affichage des 100 premières PRs", + "loadMore": "Charger plus", + "loadingMore": "Chargement...", "workflowsAwaitingApproval": "{{count}} workflow en attente d'approbation", "workflowsAwaitingApproval_plural": "{{count}} workflows en attente d'approbation", "blockedByWorkflows": "Bloqué",