import React from 'react'; import { RiChat4Line, RiCheckLine, RiCheckboxCircleLine, RiAiGenerate2, RiArrowDownSLine, RiArrowRightSLine, RiCheckboxBlankLine, RiCheckboxLine, RiCloseLine, RiEditLine, RiErrorWarningLine, RiExternalLinkLine, RiGitClosePullRequestLine, RiGitMergeLine, RiGitPrDraftLine, RiGitPullRequestLine, RiInformationLine, RiLoader4Line, } from '@remixicon/react'; import { toast } from '@/components/ui'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible'; import { generatePullRequestDescription } from '@/lib/gitApi'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useDeviceInfo } from '@/lib/device'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; import { useUIStore } from '@/stores/useUIStore'; import { useMessageStore } from '@/stores/messageStore'; import { useSessionStore } from '@/stores/useSessionStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import type { GitHubPullRequest, GitHubCheckRun, GitHubPullRequestContextResult, GitHubPullRequestStatus, GitRemote, } from '@/lib/api/types'; type MergeMethod = 'merge' | 'squash' | 'rebase'; const PR_REVALIDATE_TTL_MS = 90_000; const PR_REVALIDATE_INTERVAL_MS = 30_000; const PR_DISCOVERY_INTERVAL_MS = 5 * 60_000; const statusColor = (state: string | undefined | null): string => { switch (state) { case 'success': return 'bg-[color:var(--status-success)]'; case 'failure': return 'bg-[color:var(--status-error)]'; case 'pending': return 'bg-[color:var(--status-warning)]'; default: return 'bg-muted-foreground/40'; } }; const getPrVisualState = (status: GitHubPullRequestStatus | null): 'draft' | 'open' | 'blocked' | 'merged' | 'closed' | null => { const pr = status?.pr; if (!pr) { return null; } if (pr.state === 'merged') { return 'merged'; } if (pr.state === 'closed') { return 'closed'; } if (pr.draft) { return 'draft'; } const checksFailed = status?.checks?.state === 'failure'; const notMergeable = status?.canMerge === false || pr.mergeable === false; if (checksFailed || notMergeable) { return 'blocked'; } return 'open'; }; const branchToTitle = (branch: string): string => { return branch .replace(/^refs\/heads\//, '') .replace(/[-_]+/g, ' ') .replace(/\s+/g, ' ') .trim() .replace(/\b\w/g, (c) => c.toUpperCase()); }; const normalizeBranchRef = (value: string): string => { let normalized = value.trim(); if (!normalized) { return ''; } if (normalized.startsWith('refs/heads/')) { normalized = normalized.slice('refs/heads/'.length); } if (normalized.startsWith('heads/')) { normalized = normalized.slice('heads/'.length); } if (normalized.startsWith('remotes/')) { normalized = normalized.slice('remotes/'.length); } return normalized; }; const remoteBranchToName = (value: string, remoteName: string | null): string => { const normalized = normalizeBranchRef(value); if (!normalized || normalized.includes('->')) { return ''; } if (remoteName) { const prefix = `${remoteName}/`; if (normalized.startsWith(prefix)) { return normalized.slice(prefix.length).trim(); } return ''; } const slashIndex = normalized.indexOf('/'); if (slashIndex > 0) { return normalized.slice(slashIndex + 1).trim(); } return normalized; }; const getPullRequestSnapshotKey = (directory: string, branch: string): string => `${directory}::${branch}`; type PullRequestDraftSnapshot = { title: string; body: string; draft: boolean; additionalContext: string; targetBaseBranch?: string; selectedRemoteName?: string; }; const getTrackingRemoteName = (trackingBranch: string | null | undefined): string => { const normalized = String(trackingBranch || '').trim(); if (!normalized) { return ''; } const slashIndex = normalized.indexOf('/'); if (slashIndex <= 0) { return ''; } return normalized.slice(0, slashIndex).trim(); }; const pickInitialPrRemote = ( remotes: GitRemote[], options: { selectedRemoteName?: string; trackingBranch?: string } ): GitRemote | null => { if (remotes.length === 0) { return null; } const selectedRemoteName = String(options.selectedRemoteName || '').trim(); if (selectedRemoteName) { const fromSnapshot = remotes.find((remote) => remote.name === selectedRemoteName); if (fromSnapshot) { return fromSnapshot; } } const trackingRemoteName = getTrackingRemoteName(options.trackingBranch); if (trackingRemoteName) { const maybeUpstream = trackingRemoteName === 'origin' ? remotes.find((remote) => remote.name === 'upstream') : null; if (maybeUpstream) { return maybeUpstream; } const fromTracking = remotes.find((remote) => remote.name === trackingRemoteName); if (fromTracking) { return fromTracking; } } const originRemote = remotes.find((remote) => remote.name === 'origin'); if (originRemote) { return originRemote; } return remotes[0] ?? null; }; type TimelineCommentItem = { id: string; body: string; authorName: string; authorLogin: string | null; avatarUrl: string | null; createdAt?: string; context: string; path: string | null; line: number | null; }; type ChatDispatchTarget = { sessionId: string; providerID: string; modelID: string; currentAgentName: string | null; currentVariant: string | null; }; const pullRequestDraftSnapshots = new Map(); const pullRequestStatusSnapshots = new Map(); type TauriShell = { shell?: { open?: (url: string) => Promise; }; }; const openExternal = async (url: string) => { if (typeof window === 'undefined') return; const tauri = (window as unknown as { __TAURI__?: TauriShell }).__TAURI__; if (tauri?.shell?.open) { try { await tauri.shell.open(url); return; } catch { // fall through } } try { window.open(url, '_blank', 'noopener,noreferrer'); } catch { // ignore } }; export const PullRequestSection: React.FC<{ directory: string; branch: string; baseBranch: string; trackingBranch?: string; remotes?: GitRemote[]; remoteBranches?: string[]; variant?: 'framed' | 'plain'; onGeneratedDescription?: () => void; }> = ({ directory, branch, baseBranch, trackingBranch, remotes = [], remoteBranches = [], variant = 'framed', onGeneratedDescription }) => { const { github } = useRuntimeAPIs(); const githubAuthStatus = useGitHubAuthStore((state) => state.status); const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSidebarSection = useUIStore((state) => state.setSidebarSection); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const currentSessionId = useSessionStore((state) => state.currentSessionId); const { isMobile, hasTouchInput } = useDeviceInfo(); const openGitHubSettings = React.useCallback(() => { setSidebarSection('settings'); setSettingsDialogOpen(true); }, [setSettingsDialogOpen, setSidebarSection]); const snapshotKey = React.useMemo(() => getPullRequestSnapshotKey(directory, branch), [directory, branch]); const initialSnapshot = React.useMemo( () => pullRequestDraftSnapshots.get(snapshotKey) ?? null, [snapshotKey] ); const initialStatusSnapshot = React.useMemo( () => pullRequestStatusSnapshots.get(snapshotKey) ?? null, [snapshotKey] ); const [isLoading, setIsLoading] = React.useState(false); const [status, setStatus] = React.useState(() => initialStatusSnapshot); const [error, setError] = React.useState(null); const [isInitialStatusResolved, setIsInitialStatusResolved] = React.useState(() => Boolean(initialStatusSnapshot)); const [title, setTitle] = React.useState(() => initialSnapshot?.title ?? branchToTitle(branch)); const [body, setBody] = React.useState(() => initialSnapshot?.body ?? ''); const [draft, setDraft] = React.useState(() => initialSnapshot?.draft ?? false); const [additionalContext, setAdditionalContext] = React.useState(() => initialSnapshot?.additionalContext ?? ''); const [targetBaseBranch, setTargetBaseBranch] = React.useState(() => { const fromSnapshot = typeof initialSnapshot?.targetBaseBranch === 'string' ? normalizeBranchRef(initialSnapshot.targetBaseBranch) : ''; if (fromSnapshot) { return fromSnapshot; } return normalizeBranchRef(baseBranch); }); const [mergeMethod, setMergeMethod] = React.useState('squash'); const [isGenerating, setIsGenerating] = React.useState(false); const [isCreating, setIsCreating] = React.useState(false); const [isUpdating, setIsUpdating] = React.useState(false); const [isMerging, setIsMerging] = React.useState(false); const [isMarkingReady, setIsMarkingReady] = React.useState(false); const [isEditingPr, setIsEditingPr] = React.useState(false); const [hydratingPrBodyKey, setHydratingPrBodyKey] = React.useState(null); const [editTitle, setEditTitle] = React.useState(''); const [editBody, setEditBody] = React.useState(''); const [isContextOpen, setIsContextOpen] = React.useState(false); const [isContextSheetOpen, setIsContextSheetOpen] = React.useState(false); const [selectedRemote, setSelectedRemote] = React.useState(() => pickInitialPrRemote(remotes, { selectedRemoteName: initialSnapshot?.selectedRemoteName, trackingBranch, }) ); const availableBaseBranches = React.useMemo(() => { const selectedRemoteName = selectedRemote?.name?.trim() || null; const unique = new Set(); for (const remoteBranch of remoteBranches) { const branchName = remoteBranchToName(remoteBranch, selectedRemoteName); if (!branchName || branchName === 'HEAD') { continue; } unique.add(branchName); } const defaultBase = normalizeBranchRef(baseBranch); if (defaultBase && defaultBase !== 'HEAD') { unique.add(defaultBase); } const currentTarget = normalizeBranchRef(targetBaseBranch); if (currentTarget && currentTarget !== 'HEAD') { unique.add(currentTarget); } return Array.from(unique).sort((a, b) => a.localeCompare(b)); }, [baseBranch, remoteBranches, selectedRemote?.name, targetBaseBranch]); const hasMultipleRemotes = remotes.length > 1; // Update selected remote when remotes change React.useEffect(() => { if (remotes.length === 0) { if (selectedRemote) { setSelectedRemote(null); } return; } if (!selectedRemote || !remotes.some((remote) => remote.name === selectedRemote.name)) { setSelectedRemote( pickInitialPrRemote(remotes, { selectedRemoteName: initialSnapshot?.selectedRemoteName, trackingBranch, }) ); } }, [initialSnapshot?.selectedRemoteName, remotes, selectedRemote, trackingBranch]); React.useEffect(() => { const normalizedBase = normalizeBranchRef(baseBranch); if (!targetBaseBranch && normalizedBase) { setTargetBaseBranch(normalizedBase); return; } if (availableBaseBranches.length === 0) { return; } if (!availableBaseBranches.includes(targetBaseBranch)) { const fallback = availableBaseBranches.includes(normalizedBase) ? normalizedBase : availableBaseBranches[0]; if (fallback) { setTargetBaseBranch(fallback); } } }, [availableBaseBranches, baseBranch, targetBaseBranch]); const [checksDialogOpen, setChecksDialogOpen] = React.useState(false); const [checkDetails, setCheckDetails] = React.useState(null); const [isLoadingCheckDetails, setIsLoadingCheckDetails] = React.useState(false); const [expandedCheckStepKeys, setExpandedCheckStepKeys] = React.useState>(new Set()); const [commentsDialogOpen, setCommentsDialogOpen] = React.useState(false); const [commentsDetails, setCommentsDetails] = React.useState(null); const [isLoadingCommentsDetails, setIsLoadingCommentsDetails] = React.useState(false); const isRefreshInFlightRef = React.useRef(false); const lastRefreshAtRef = React.useRef(0); const lastDiscoveryPollAtRef = React.useRef(0); const statusRef = React.useRef(null); const attemptedBodyHydrationRef = React.useRef>(new Set()); const lastSyncedPrNumberRef = React.useRef(null); const canShow = Boolean(directory && branch && baseBranch && branch !== baseBranch); const pr = status?.pr ?? null; const currentPrBodyHydrationKey = pr ? `${directory}#${pr.number}` : null; const isHydratingCurrentPrBody = Boolean( currentPrBodyHydrationKey && hydratingPrBodyKey === currentPrBodyHydrationKey, ); React.useEffect(() => { if (!github?.prContext || !pr) { return; } if (typeof pr.body === 'string' && pr.body.length > 0) { return; } const hydrationKey = `${directory}#${pr.number}`; if (attemptedBodyHydrationRef.current.has(hydrationKey)) { return; } attemptedBodyHydrationRef.current.add(hydrationKey); setHydratingPrBodyKey(hydrationKey); let cancelled = false; void github.prContext(directory, pr.number, { includeDiff: false, includeCheckDetails: false }) .then((ctx) => { if (cancelled) { return; } const ctxPr = ctx?.pr; if (!ctxPr) { return; } setStatus((prev) => { if (!prev?.pr || prev.pr.number !== pr.number) { return prev; } return { ...prev, pr: { ...prev.pr, body: ctxPr.body || '', }, }; }); }) .catch(() => {}) .finally(() => { if (cancelled) { return; } setHydratingPrBodyKey((prev) => (prev === hydrationKey ? null : prev)); }); return () => { cancelled = true; }; }, [directory, github, pr]); React.useEffect(() => { if (!pr) { setIsEditingPr(false); setEditTitle(''); setEditBody(''); lastSyncedPrNumberRef.current = null; return; } const numberChanged = lastSyncedPrNumberRef.current !== null && lastSyncedPrNumberRef.current !== pr.number; if (numberChanged) { setIsEditingPr(false); } if (!isEditingPr || numberChanged) { setEditTitle(pr.title || ''); setEditBody(pr.body || ''); } lastSyncedPrNumberRef.current = pr.number; }, [isEditingPr, pr]); const openChecksDialog = React.useCallback(async () => { if (!github?.prContext) { toast.error('GitHub runtime API unavailable'); return; } if (!pr) return; setChecksDialogOpen(true); setExpandedCheckStepKeys(new Set()); setIsLoadingCheckDetails(true); try { const ctx = await github.prContext(directory, pr.number, { includeDiff: false, includeCheckDetails: true, }); setCheckDetails(ctx); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to load check details', { description: message }); } finally { setIsLoadingCheckDetails(false); } }, [directory, github, pr]); const openCommentsDialog = React.useCallback(async () => { if (!github?.prContext) { toast.error('GitHub runtime API unavailable'); return; } if (!pr) return; setCommentsDialogOpen(true); setIsLoadingCommentsDetails(true); try { const ctx = await github.prContext(directory, pr.number, { includeDiff: false, includeCheckDetails: false, }); setCommentsDetails(ctx); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to load comments', { description: message }); } finally { setIsLoadingCommentsDetails(false); } }, [directory, github, pr]); const formatTimestamp = React.useCallback((value?: string) => { if (!value) return ''; const ts = Date.parse(value); if (!Number.isFinite(ts)) { return value; } return new Date(ts).toLocaleString(); }, []); const connectedGitHubLogin = React.useMemo(() => { const login = githubAuthStatus?.user?.login; return typeof login === 'string' ? login.trim() : ''; }, [githubAuthStatus]); const selfMentionHighlightClass = React.useMemo(() => { return "[&_a[href*='oc-self-mention=1']]:!text-[var(--primary-base)] [&_a[href*='oc-self-mention=1']]:font-semibold [&_a[href*='oc-self-mention=1']]:!no-underline [&_a[href*='oc-self-mention=1']:hover]:!text-[var(--primary-hover)]"; }, []); const linkifyMentionsMarkdown = React.useCallback((content: string) => { const selfLoginLower = connectedGitHubLogin.toLowerCase(); const mentionRegex = /(^|[^\w`])@([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,38}))/g; return content.replace(mentionRegex, (_match, prefix: string, username: string) => { const mention = `@${username}`; const usernameLower = username.toLowerCase(); const selfTag = selfLoginLower && usernameLower === selfLoginLower ? '?oc-self-mention=1' : ''; return `${prefix}[${mention}](https://github.com/${usernameLower}${selfTag})`; }); }, [connectedGitHubLogin]); const timelineComments = React.useMemo(() => { const issue = (commentsDetails?.issueComments ?? []).map((comment) => ({ id: `issue-${comment.id}`, body: comment.body || '', authorName: comment.author?.name || comment.author?.login || 'Unknown author', authorLogin: comment.author?.login || null, avatarUrl: comment.author?.avatarUrl || null, createdAt: comment.createdAt, context: 'General comment', path: null as string | null, line: null as number | null, })); const review = (commentsDetails?.reviewComments ?? []).map((comment) => ({ id: `review-${comment.id}`, body: comment.body || '', authorName: comment.author?.name || comment.author?.login || 'Unknown author', authorLogin: comment.author?.login || null, avatarUrl: comment.author?.avatarUrl || null, createdAt: comment.createdAt, context: 'Code review comment', path: comment.path || null, line: comment.line ?? null, })); const all = [...issue, ...review]; all.sort((a, b) => { const aTs = a.createdAt ? Date.parse(a.createdAt) : 0; const bTs = b.createdAt ? Date.parse(b.createdAt) : 0; const aVal = Number.isFinite(aTs) ? aTs : 0; const bVal = Number.isFinite(bTs) ? bTs : 0; return aVal - bVal; }); return all; }, [commentsDetails]); const resolveChatDispatchTarget = React.useCallback((): ChatDispatchTarget | null => { if (!currentSessionId) { toast.error('No active session', { description: 'Open a chat session first.' }); return null; } const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore.getState(); const lastUsedProvider = useMessageStore.getState().lastUsedProvider; const providerID = currentProviderId || lastUsedProvider?.providerID; const modelID = currentModelId || lastUsedProvider?.modelID; if (!providerID || !modelID) { toast.error('No model selected'); return null; } return { sessionId: currentSessionId, providerID, modelID, currentAgentName: currentAgentName ?? null, currentVariant: currentVariant ?? null, }; }, [currentSessionId]); const dispatchSyntheticPrompt = React.useCallback(( target: ChatDispatchTarget, visibleText: string, instructionsText: string, payloadText: string, ) => { void useMessageStore.getState().sendMessage( visibleText, target.providerID, target.modelID, target.currentAgentName ?? undefined, target.sessionId, undefined, null, [ { text: instructionsText, synthetic: true }, { text: payloadText, synthetic: true }, ], target.currentVariant ?? undefined, ).catch((e) => { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to send message', { description: message }); }); }, []); const renderCheckRunSummary = React.useCallback((run: GitHubCheckRun) => { const status = run.status || 'unknown'; const conclusion = run.conclusion ?? undefined; const statusText = conclusion ? `${status} / ${conclusion}` : status; const appName = run.app?.name || run.app?.slug; return (
{run.name}
{appName ? `${appName} · ${statusText}` : statusText}
{run.detailsUrl ? ( ) : null}
{run.output?.title ? (
{run.output.title}
) : null} {run.output?.summary ? (
{run.output.summary}
) : null} {run.output?.text ? (
{run.output.text}
) : null} {Array.isArray(run.annotations) && run.annotations.length > 0 ? (
Failed annotations{run.annotations.length > 20 ? ` (showing 20/${run.annotations.length})` : ''}
{run.annotations.slice(0, 20).map((annotation, idx) => (
{annotation.title || annotation.level || 'Issue'} {annotation.path ? ` · ${annotation.path}` : ''} {typeof annotation.startLine === 'number' ? `:${annotation.startLine}` : ''} {typeof annotation.endLine === 'number' && annotation.endLine !== annotation.startLine ? `-${annotation.endLine}` : ''}
{annotation.message}
{annotation.rawDetails ? (
{annotation.rawDetails}
) : null}
))}
) : null} {run.job?.steps && run.job.steps.length > 0 ? (
Steps
{run.job.steps.map((step, idx) => { const c = (step.conclusion || '').toLowerCase(); const isFail = c && !['success', 'neutral', 'skipped'].includes(c); const stepKey = `${run.id ?? 'run'}:${run.job?.jobId ?? 'job'}:${step.number ?? idx}:${step.name}`; const stepExpanded = expandedCheckStepKeys.has(stepKey); if (!isFail) { return (
{step.name} {step.conclusion ? {step.conclusion} : null}
); } return (
{typeof step.number === 'number' ?
Step: {step.number}
: null} {step.status ?
Status: {step.status}
: null} {step.conclusion ?
Conclusion: {step.conclusion}
: null} {step.startedAt ?
Started: {formatTimestamp(step.startedAt)}
: null} {step.completedAt ?
Completed: {formatTimestamp(step.completedAt)}
: null}
); })}
) : null}
); }, [expandedCheckStepKeys, formatTimestamp]); const sendFailedChecksToChat = React.useCallback(async () => { setActiveMainTab('chat'); if (!github?.prContext) { toast.error('GitHub runtime API unavailable'); return; } if (!directory || !pr) return; const target = resolveChatDispatchTarget(); if (!target) { return; } try { const context = await github.prContext(directory, pr.number, { includeDiff: false, includeCheckDetails: true }); const runs = context.checkRuns ?? []; const failed = runs.filter((r) => { const conclusion = typeof r.conclusion === 'string' ? r.conclusion.toLowerCase() : ''; if (!conclusion) return false; return !['success', 'neutral', 'skipped'].includes(conclusion); }); if (failed.length === 0) { toast.message('No failed checks'); return; } const visibleText = 'Review these PR failed checks and propose likely fixes. Do not implement until I confirm.'; const instructionsText = `Use the attached checks payload. - Summarize what is failing. - Prioritize check annotations/errors over generic status text. - Identify likely root cause(s). - Propose a minimal fix plan and verification steps. - No speculation: ask for missing info if needed.`; const failedAnnotations = failed.flatMap((run) => { const annotations = Array.isArray(run.annotations) ? run.annotations : []; return annotations.map((annotation) => ({ run: run.name, level: annotation.level, title: annotation.title, path: annotation.path, startLine: annotation.startLine, endLine: annotation.endLine, message: annotation.message, rawDetails: annotation.rawDetails, })); }); const payloadText = `GitHub PR failed checks (JSON)\n${JSON.stringify({ repo: context.repo ?? null, pr: context.pr ?? null, failedChecks: failed, failedAnnotations, }, null, 2)}`; dispatchSyntheticPrompt(target, visibleText, instructionsText, payloadText); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to load checks', { description: message }); } }, [directory, dispatchSyntheticPrompt, github, pr, resolveChatDispatchTarget, setActiveMainTab]); const sendCommentsToChat = React.useCallback(async () => { setActiveMainTab('chat'); if (!github?.prContext) { toast.error('GitHub runtime API unavailable'); return; } if (!directory || !pr) return; const target = resolveChatDispatchTarget(); if (!target) { return; } try { const context = await github.prContext(directory, pr.number, { includeDiff: false, includeCheckDetails: false }); const issueComments = context.issueComments ?? []; const reviewComments = context.reviewComments ?? []; const total = issueComments.length + reviewComments.length; if (total === 0) { toast.message('No PR comments'); return; } const visibleText = 'Review these PR comments and propose the required changes and next actions. Do not implement until I confirm.'; const instructionsText = `Use the attached comments payload. - Identify required vs optional changes. - Call out intent/implementation mismatch if present. - Propose a minimal plan and verification steps. - No speculation: ask for missing info if needed.`; const payloadText = `GitHub PR comments (JSON)\n${JSON.stringify({ repo: context.repo ?? null, pr: context.pr ?? null, issueComments, reviewComments, }, null, 2)}`; dispatchSyntheticPrompt(target, visibleText, instructionsText, payloadText); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to load PR comments', { description: message }); } }, [directory, dispatchSyntheticPrompt, github, pr, resolveChatDispatchTarget, setActiveMainTab]); const sendSingleCommentToChat = React.useCallback((comment: TimelineCommentItem) => { setCommentsDialogOpen(false); setActiveMainTab('chat'); const target = resolveChatDispatchTarget(); if (!target) { return; } const visibleText = 'Address this comment from PR and propose required changes. Do not implement until I confirm.'; const instructionsText = `Use the attached single-comment payload. - Explain what the reviewer is asking for. - Identify exact code areas likely impacted. - Propose a minimal implementation plan and verification steps. - Call out ambiguity and ask focused follow-up questions if needed.`; const payloadText = `GitHub PR comment (JSON)\n${JSON.stringify({ repo: commentsDetails?.repo ?? null, pr: commentsDetails?.pr ?? pr ?? null, comment, }, null, 2)}`; dispatchSyntheticPrompt(target, visibleText, instructionsText, payloadText); }, [commentsDetails, dispatchSyntheticPrompt, pr, resolveChatDispatchTarget, setActiveMainTab]); React.useEffect(() => { statusRef.current = status; }, [status]); const refresh = React.useCallback(async (options?: { force?: boolean; onlyExistingPr?: boolean; silent?: boolean; markInitialResolved?: boolean }) => { if (!canShow) return; if (options?.onlyExistingPr && !statusRef.current?.pr) { return; } if (!options?.force && Date.now() - lastRefreshAtRef.current < PR_REVALIDATE_TTL_MS) { return; } if (isRefreshInFlightRef.current) { return; } isRefreshInFlightRef.current = true; lastRefreshAtRef.current = Date.now(); if (githubAuthChecked && githubAuthStatus?.connected === false) { setStatus({ connected: false }); setError(null); if (!options?.silent) { setIsLoading(false); } if (options?.markInitialResolved !== false) { setIsInitialStatusResolved(true); } isRefreshInFlightRef.current = false; return; } if (!github?.prStatus) { setStatus(null); setError('GitHub runtime API unavailable'); if (options?.markInitialResolved !== false) { setIsInitialStatusResolved(true); } isRefreshInFlightRef.current = false; return; } if (!options?.silent) { setIsLoading(true); } setError(null); try { const next = await github.prStatus(directory, branch, selectedRemote?.name); setStatus((prev) => { const nextPr = next.pr; const prevPr = prev?.pr; // Some runtimes occasionally return PR status without body. // Keep already hydrated description for the same PR number. const shouldCarryBody = Boolean( nextPr && prevPr && nextPr.number === prevPr.number && (!nextPr.body || !nextPr.body.trim()) && typeof prevPr.body === 'string' && prevPr.body.trim().length > 0, ); if (!shouldCarryBody || !nextPr) { return next; } const carriedBody = prevPr?.body; if (!carriedBody) { return next; } return { ...next, pr: { ...nextPr, body: carriedBody, }, }; }); if (next.connected === false) { setError(null); } } catch (e) { const message = e instanceof Error ? e.message : String(e); setError(message || 'Failed to load PR status'); } finally { if (!options?.silent) { setIsLoading(false); } if (options?.markInitialResolved !== false) { setIsInitialStatusResolved(true); } isRefreshInFlightRef.current = false; } }, [branch, canShow, directory, github, githubAuthChecked, githubAuthStatus, selectedRemote?.name]); // Refetch PR status when selected remote changes const handleRemoteChange = React.useCallback((remote: GitRemote) => { setSelectedRemote(remote); // Clear current status and refetch setStatus(null); setError(null); lastRefreshAtRef.current = 0; // Force refresh }, []); React.useEffect(() => { const snapshot = pullRequestDraftSnapshots.get(snapshotKey) ?? null; const statusSnapshot = pullRequestStatusSnapshots.get(snapshotKey) ?? null; setTitle(snapshot?.title ?? branchToTitle(branch)); setBody(snapshot?.body ?? ''); setDraft(snapshot?.draft ?? false); setTargetBaseBranch(snapshot?.targetBaseBranch ? normalizeBranchRef(snapshot.targetBaseBranch) : normalizeBranchRef(baseBranch)); setSelectedRemote( pickInitialPrRemote(remotes, { selectedRemoteName: snapshot?.selectedRemoteName, trackingBranch, }) ); setStatus(statusSnapshot); setError(null); setIsInitialStatusResolved(Boolean(statusSnapshot)); void refresh({ force: true, markInitialResolved: true }); }, [baseBranch, branch, refresh, remotes, snapshotKey, trackingBranch]); // Refetch when selected remote changes React.useEffect(() => { if (selectedRemote) { void refresh({ force: true, markInitialResolved: true }); } }, [selectedRemote, refresh]); React.useEffect(() => { const onFocus = () => { void refresh({ force: true, silent: true }); }; const onVisibility = () => { if (document.visibilityState === 'visible') { void refresh({ force: true, silent: true }); } }; window.addEventListener('focus', onFocus); document.addEventListener('visibilitychange', onVisibility); return () => { window.removeEventListener('focus', onFocus); document.removeEventListener('visibilitychange', onVisibility); }; }, [refresh]); React.useEffect(() => { const interval = window.setInterval(() => { if (document.visibilityState !== 'visible') { return; } const hasPr = Boolean(statusRef.current?.pr); if (!hasPr) { const now = Date.now(); const shouldRunDiscovery = now - lastDiscoveryPollAtRef.current >= PR_DISCOVERY_INTERVAL_MS; if (!shouldRunDiscovery) { return; } lastDiscoveryPollAtRef.current = now; void refresh({ force: true, silent: true }); return; } void refresh({ onlyExistingPr: true, force: true, silent: true }); }, PR_REVALIDATE_INTERVAL_MS); return () => { window.clearInterval(interval); }; }, [refresh]); React.useEffect(() => { if (githubAuthChecked && githubAuthStatus?.connected === false) { setStatus({ connected: false }); setError(null); } }, [githubAuthChecked, githubAuthStatus]); React.useEffect(() => { if (!directory || !branch) { return; } pullRequestDraftSnapshots.set(snapshotKey, { title, body, draft, additionalContext, targetBaseBranch, selectedRemoteName: selectedRemote?.name, }); }, [snapshotKey, title, body, draft, additionalContext, targetBaseBranch, selectedRemote?.name, directory, branch]); React.useEffect(() => { if (!status) { return; } pullRequestStatusSnapshots.set(snapshotKey, status); }, [snapshotKey, status]); const generateDescription = React.useCallback(async () => { if (isGenerating) return; if (!directory) return; setIsGenerating(true); try { const zenModel = useConfigStore.getState().settingsZenModel; const generated = await generatePullRequestDescription(directory, { base: targetBaseBranch, head: branch, context: additionalContext, ...(zenModel ? { zenModel } : {}), }); if (generated.title?.trim()) { setTitle(generated.title.trim()); } if (generated.body?.trim()) { setBody(generated.body.trim()); } onGeneratedDescription?.(); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to generate description', { description: message }); } finally { setIsGenerating(false); } }, [branch, directory, isGenerating, additionalContext, onGeneratedDescription, targetBaseBranch]); const createPr = React.useCallback(async () => { if (!github?.prCreate) { toast.error('GitHub runtime API unavailable'); return; } const trimmedTitle = title.trim(); if (!trimmedTitle) { toast.error('Title is required'); return; } const trimmedBase = targetBaseBranch.trim(); if (!trimmedBase) { toast.error('Base branch is required'); return; } if (trimmedBase === branch) { toast.error('Base branch must differ from head branch'); return; } setIsCreating(true); try { // Let the server determine the head source from tracking info // The server will check the branch's tracking remote and use that const pr = await github.prCreate({ directory, title: trimmedTitle, head: branch, base: trimmedBase, ...(body.trim() ? { body } : {}), draft, ...(selectedRemote ? { remote: selectedRemote.name } : {}), }); toast.success('PR created'); setStatus((prev) => (prev ? { ...prev, pr } : prev)); await refresh({ force: true }); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to create PR', { description: message }); } finally { setIsCreating(false); } }, [body, branch, directory, draft, github, refresh, selectedRemote, targetBaseBranch, title]); const mergePr = React.useCallback(async (pr: GitHubPullRequest) => { if (!github?.prMerge) { toast.error('GitHub runtime API unavailable'); return; } setIsMerging(true); try { const result = await github.prMerge({ directory, number: pr.number, method: mergeMethod }); if (result.merged) { toast.success('PR merged'); } else { toast.message('PR not merged', { description: result.message || 'Not mergeable' }); } await refresh({ force: true }); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Merge failed', { description: message }); if (pr.url) { void openExternal(pr.url); } } finally { setIsMerging(false); } }, [directory, github, mergeMethod, refresh]); const markReady = React.useCallback(async (pr: GitHubPullRequest) => { if (!github?.prReady) { toast.error('GitHub runtime API unavailable'); return; } setIsMarkingReady(true); try { await github.prReady({ directory, number: pr.number }); toast.success('Marked ready for review'); await refresh({ force: true }); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to mark ready', { description: message }); if (pr.url) { void openExternal(pr.url); } } finally { setIsMarkingReady(false); } }, [directory, github, refresh]); const updatePr = React.useCallback(async (pr: GitHubPullRequest) => { if (!github?.prUpdate) { toast.error('GitHub runtime API unavailable'); return; } const trimmedTitle = editTitle.trim(); if (!trimmedTitle) { toast.error('Title is required'); return; } setIsUpdating(true); try { const updated = await github.prUpdate({ directory, number: pr.number, title: trimmedTitle, body: editBody, }); setStatus((prev) => (prev ? { ...prev, pr: { ...(prev.pr ?? pr), ...updated, }, } : prev)); setIsEditingPr(false); toast.success('PR updated'); await refresh({ force: true }); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to update PR', { description: message }); } finally { setIsUpdating(false); } }, [directory, editBody, editTitle, github, refresh]); if (!canShow) { return null; } const repoUrl = status?.repo?.url || null; const checks = status?.checks ?? null; const canMerge = Boolean(status?.canMerge); const isConnected = Boolean(status?.connected); const shouldShowConnectionNotice = githubAuthChecked && status?.connected === false; const prVisualState = getPrVisualState(status); const prColorVar = prVisualState ? `var(--pr-${prVisualState})` : 'var(--status-info)'; const PrStateIcon = prVisualState === 'draft' ? RiGitPrDraftLine : prVisualState === 'merged' ? RiGitMergeLine : prVisualState === 'closed' ? RiGitClosePullRequestLine : RiGitPullRequestLine; const containerClassName = variant === 'framed' ? 'rounded-xl border border-border/60 bg-background/70 overflow-hidden' : 'border-0 bg-transparent rounded-none'; const headerClassName = variant === 'framed' ? 'px-3 py-2 border-b border-border/40 flex flex-col gap-1' : 'px-0 py-3 border-b border-border/40 flex flex-col gap-1'; const bodyClassName = variant === 'framed' ? 'flex flex-col gap-3 p-3' : 'flex flex-col gap-3 py-3'; return (
{pr ? (

Open PR on GitHub

) : ( )}

Pull Request

{pr ? ( #{pr.number} ) : null}
{isLoading ? : null} {checks ? ( {checks.total > 0 ? `${checks.success}/${checks.total} checks` : `${checks.state} checks`} ) : null} {hasMultipleRemotes ? ( {remotes.map((remote) => ( handleRemoteChange(remote)} >
{remote.name} {remote.name === selectedRemote?.name && ( )} {remote.pushUrl}
))}
) : null}
{pr ? (
{pr.state}{pr.draft ? ' (draft)' : ''} {pr.mergeable === false ? ' · not mergeable' : ''} {pr.state === 'open' && typeof pr.mergeableState === 'string' && pr.mergeableState && pr.mergeableState !== 'unknown' ? ` · ${pr.mergeableState}` : ''}
) : null}
{shouldShowConnectionNotice ? (
GitHub not connected. Connect your GitHub account in settings.
) : null} {error ? (
PR status unavailable
{error}
{repoUrl ? ( ) : null}
) : null} {!pr && !isInitialStatusResolved && !error && !shouldShowConnectionNotice ? (
Checking PR status...
) : pr ? (
{isEditingPr ? (
setEditTitle(e.target.value)} placeholder="PR title" autoCorrect={hasTouchInput ? "on" : "off"} autoCapitalize={hasTouchInput ? "sentences" : "off"} spellCheck={hasTouchInput} />