import React from 'react'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { toast } from '@/components/ui'; import { Icon } from "@/components/icon/Icon"; import { cn } from '@/lib/utils'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useUIStore } from '@/stores/useUIStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { renderMagicPrompt } from '@/lib/magicPrompts'; import { useDeviceInfo } from '@/lib/device'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import type { GitHubPullRequestContextResult, GitHubPullRequestSummary, GitHubPullRequestsListResult, GitHubRepoSelector } from '@/lib/api/types'; import { useI18n } from '@/lib/i18n'; const parsePrNumber = (value: string): number | null => { const trimmed = value.trim(); if (!trimmed) return null; const urlMatch = trimmed.match(/\/pull\/(\d+)(?:\b|\/|$)/i); if (urlMatch) { const parsed = Number(urlMatch[1]); return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } const hashMatch = trimmed.match(/^#?(\d+)$/); if (hashMatch) { const parsed = Number(hashMatch[1]); return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } return null; }; const buildPullRequestContextText = (payload: GitHubPullRequestContextResult) => { return `GitHub pull request context (JSON)\n${JSON.stringify(payload, null, 2)}`; }; export function GitHubPrPickerDialog({ open, onOpenChange, onSelect, }: { open: boolean; onOpenChange: (open: boolean) => void; onSelect?: (pr: { number: number; title: string; url: string; head: string; base: string; includeDiff: boolean; instructionsText: string; contextText: string; author?: { login: string; avatarUrl?: string }; }) => void; }) { const { t } = useI18n(); const { github } = useRuntimeAPIs(); const githubAuthStatus = useGitHubAuthStore((state) => state.status); const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); const isMobile = useUIStore((state) => state.isMobile); const { isTablet } = useDeviceInfo(); const alwaysShowActions = isMobile || isTablet; const activeProject = useProjectsStore((state) => state.getActiveProject()); const projectDirectory = activeProject?.path ?? null; const [query, setQuery] = React.useState(''); const [includeDiff, setIncludeDiff] = React.useState(false); const [result, setResult] = React.useState(null); const [prs, setPrs] = React.useState([]); const [page, setPage] = React.useState(1); const [hasMore, setHasMore] = React.useState(false); const [loadingPrNumber, setLoadingPrNumber] = React.useState(null); const [isLoading, setIsLoading] = React.useState(false); const [isLoadingMore, setIsLoadingMore] = React.useState(false); const [error, setError] = React.useState(null); const directNumber = React.useMemo(() => parsePrNumber(query), [query]); const debouncedQuery = useDebouncedValue(query, 350); const isTextSearch = debouncedQuery.trim().length > 0 && !directNumber; const refresh = React.useCallback(async () => { if (!projectDirectory) { setResult(null); setError(t('session.githubPrPicker.error.noActiveProject')); return; } if (githubAuthChecked && githubAuthStatus?.connected === false) { setResult({ connected: false }); setPrs([]); setHasMore(false); setPage(1); setError(null); return; } if (!github?.prsList) { setResult(null); setError(t('session.githubPrPicker.error.runtimeUnavailable')); return; } setIsLoading(true); setError(null); try { const next = await github.prsList(projectDirectory, { page: 1 }); setResult(next); setPrs(next.prs ?? []); setPage(next.page ?? 1); setHasMore(Boolean(next.hasMore)); if (next.connected === false) { setError(null); } } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setIsLoading(false); } }, [github, githubAuthChecked, githubAuthStatus, projectDirectory, t]); React.useEffect(() => { if (!open || !projectDirectory) return; if (githubAuthChecked && githubAuthStatus?.connected === false) return; if (!github?.prsList) return; if (!debouncedQuery.trim() || directNumber) { void refresh(); return; } const controller = new AbortController(); setIsLoading(true); setError(null); github.prsList(projectDirectory, { page: 1, query: debouncedQuery.trim() }) .then((next) => { if (controller.signal.aborted) return; setResult(next); setPrs(next.prs ?? []); setPage(next.page ?? 1); setHasMore(Boolean(next.hasMore)); }) .catch((e) => { if (controller.signal.aborted) return; setError(e instanceof Error ? e.message : String(e)); }) .finally(() => { if (!controller.signal.aborted) setIsLoading(false); }); return () => controller.abort(); }, [open, projectDirectory, github, githubAuthChecked, githubAuthStatus, debouncedQuery, directNumber, refresh, t]); const loadMore = React.useCallback(async () => { if (!projectDirectory) return; if (!github?.prsList) return; if (isLoadingMore || isLoading) return; if (!hasMore) return; setIsLoadingMore(true); try { const nextPage = page + 1; const next = isTextSearch ? await github.prsList(projectDirectory, { page: nextPage, query: debouncedQuery.trim() }) : await github.prsList(projectDirectory, { page: nextPage }); setResult(next); setPrs((prev) => [...prev, ...(next.prs ?? [])]); setPage(next.page ?? nextPage); setHasMore(Boolean(next.hasMore)); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error(t('session.githubPrPicker.toast.loadMoreFailed'), { description: message }); } finally { setIsLoadingMore(false); } }, [github, hasMore, isLoading, isLoadingMore, isTextSearch, debouncedQuery, page, projectDirectory, t]); React.useEffect(() => { if (!open) { setQuery(''); setIncludeDiff(false); setLoadingPrNumber(null); setError(null); setResult(null); setPrs([]); setPage(1); setHasMore(false); setIsLoading(false); return; } void refresh(); }, [open, refresh]); React.useEffect(() => { if (!open) return; if (githubAuthChecked && githubAuthStatus?.connected === false) { setResult({ connected: false }); setPrs([]); setHasMore(false); setPage(1); setError(null); } }, [githubAuthChecked, githubAuthStatus, open]); const connected = githubAuthChecked ? result?.connected !== false : true; const openGitHubSettings = React.useCallback(() => { setSettingsPage('github'); setSettingsDialogOpen(true); }, [setSettingsDialogOpen, setSettingsPage]); const attachPr = React.useCallback(async (prNumber: number, sourceRepo?: GitHubRepoSelector | null) => { if (!projectDirectory) { toast.error(t('session.githubPrPicker.error.noActiveProject')); return; } if (!github?.prContext) { toast.error(t('session.githubPrPicker.error.runtimeUnavailable')); return; } if (loadingPrNumber) return; setLoadingPrNumber(prNumber); try { const context = await github.prContext(projectDirectory, prNumber, { includeDiff, includeCheckDetails: false, sourceRepo, }); if (context.connected === false) { toast.error(t('session.githubPrPicker.error.notConnected')); return; } if (!context.pr) { toast.error(t('session.githubPrPicker.error.prNotFound')); return; } if (!context.repo) { toast.error(t('session.githubPrPicker.error.repoNotResolvable'), { description: t('session.githubPrPicker.error.repoMustBeGithub'), }); return; } if (onSelect) { const instructionsText = await renderMagicPrompt('github.pr.review.instructions'); onSelect({ number: context.pr.number, title: context.pr.title, url: context.pr.url, head: context.pr.head, base: context.pr.base, includeDiff, instructionsText, contextText: buildPullRequestContextText(context), author: context.pr.author ? { login: context.pr.author.login, avatarUrl: context.pr.author.avatarUrl, } : undefined, }); } onOpenChange(false); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error(t('session.githubPrPicker.toast.loadDetailsFailed'), { description: message }); } finally { setLoadingPrNumber(null); } }, [github, includeDiff, loadingPrNumber, onOpenChange, onSelect, projectDirectory, t]); const title = t('session.githubPrPicker.title'); const description = t('session.githubPrPicker.description'); const content = ( <>
setQuery(e.target.value)} className="pl-9 w-full" />
{!projectDirectory ? (
{t('session.githubPrPicker.empty.noActiveProject')}
) : null} {!github ? (
{t('session.githubPrPicker.empty.runtimeUnavailable')}
) : null} {isLoading ? (
{t('session.githubPrPicker.loading.pullRequests')}
) : null} {connected === false ? (
{t('session.githubPrPicker.empty.notConnected')}
) : null} {error ? (
{error}
) : null} {directNumber && projectDirectory && github && connected ? (
void attachPr(directNumber)} > #

{t('session.githubPrPicker.actions.usePullRequest', { number: directNumber })}

{loadingPrNumber === directNumber ? ( ) : null}
) : null} {prs.length === 0 && !isLoading && connected && github && projectDirectory ? (
{debouncedQuery.trim() ? t('session.githubPrPicker.empty.noPullRequestsFound') : t('session.githubPrPicker.empty.noOpenPullRequestsFound')}
) : null} {prs.map((pr) => (
void attachPr(pr.number, pr.sourceRepo)} >

#{pr.number} {pr.title}

{pr.sourceRepo?.source === 'upstream' ? ( {pr.sourceRepo.owner}/{pr.sourceRepo.repo} ) : null}

{pr.head} → {pr.base}

))} {hasMore && connected && projectDirectory && github ? (
) : null}
); if (isMobile) { return ( onOpenChange(false)} renderHeader={(closeButton) => (

{title}

{closeButton}

{description}

)} > {content}
); } return ( {title} {description} {content} ); }