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 { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; import type { GitHubPullRequest, GitHubCheckRun, GitHubPullRequestContextResult, GitHubPullRequestStatus, GitRemote, } from '@/lib/api/types'; type MergeMethod = 'merge' | 'squash' | 'rebase'; 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 mergeableState = typeof pr.mergeableState === 'string' ? pr.mergeableState : ''; const notMergeable = pr.mergeable === false || mergeableState === 'blocked' || mergeableState === 'dirty'; if (checksFailed || notMergeable) { return 'blocked'; } return 'open'; }; const PR_ACTION_REFRESH_DELAYS_MS = [2_000, 5_000] as const; 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; }; const isEphemeralPrRemote = (name: string): boolean => name.startsWith('pr-'); const rankRemotesForAutoSelect = ( remotes: GitRemote[], trackingBranch?: string, ): GitRemote[] => { const trackingRemote = getTrackingRemoteName(trackingBranch); const byName = new Map(remotes.map((remote) => [remote.name, remote])); const ordered: GitRemote[] = []; const pushUnique = (remote: GitRemote | null | undefined) => { if (!remote) return; if (ordered.some((item) => item.name === remote.name)) return; ordered.push(remote); }; if (trackingRemote) { pushUnique(byName.get(trackingRemote)); } pushUnique(byName.get('upstream')); pushUnique(byName.get('origin')); remotes .filter((remote) => !isEphemeralPrRemote(remote.name)) .forEach((remote) => pushUnique(remote)); remotes.forEach((remote) => pushUnique(remote)); return ordered; }; 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(); 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 setSettingsPage = useUIStore((state) => state.setSettingsPage); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const currentSessionId = useSessionStore((state) => state.currentSessionId); const { isMobile, hasTouchInput } = useDeviceInfo(); const openGitHubSettings = React.useCallback(() => { setSettingsPage('github'); setSettingsDialogOpen(true); }, [setSettingsDialogOpen, setSettingsPage]); const snapshotKey = React.useMemo(() => getPullRequestSnapshotKey(directory, branch), [directory, branch]); const initialSnapshot = React.useMemo( () => pullRequestDraftSnapshots.get(snapshotKey) ?? null, [snapshotKey] ); const ensurePrStatusEntry = useGitHubPrStatusStore((state) => state.ensureEntry); const setPrStatusParams = useGitHubPrStatusStore((state) => state.setParams); const startPrStatusWatching = useGitHubPrStatusStore((state) => state.startWatching); const stopPrStatusWatching = useGitHubPrStatusStore((state) => state.stopWatching); const refreshPrStatus = useGitHubPrStatusStore((state) => state.refresh); const updatePrStatus = useGitHubPrStatusStore((state) => state.updateStatus); 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 prStatusKey = React.useMemo( () => getGitHubPrStatusKey(directory, branch), [directory, branch], ); const statusEntry = useGitHubPrStatusStore((state) => state.entries[prStatusKey]); const isLoading = statusEntry?.isLoading ?? false; const status = statusEntry?.status ?? null; const error = statusEntry?.error ?? null; const isInitialStatusResolved = statusEntry?.isInitialStatusResolved ?? false; 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 attemptedBodyHydrationRef = React.useRef>(new Set()); const lastSyncedPrNumberRef = React.useRef(null); const didUserOverrideRemoteRef = React.useRef(false); const autoRemoteProbeDoneRef = React.useRef>(new Set()); const pendingActionRefreshTimersRef = React.useRef([]); 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; } updatePrStatus(prStatusKey, (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, prStatusKey, updatePrStatus]); 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]); const refresh = React.useCallback(async (options?: { force?: boolean; onlyExistingPr?: boolean; silent?: boolean; markInitialResolved?: boolean }) => { await refreshPrStatus(prStatusKey, options); }, [prStatusKey, refreshPrStatus]); const scheduleActionRefresh = React.useCallback(() => { pendingActionRefreshTimersRef.current.forEach((timerId) => { window.clearTimeout(timerId); }); pendingActionRefreshTimersRef.current = PR_ACTION_REFRESH_DELAYS_MS.map((delayMs) => window.setTimeout(() => { void refresh({ force: true, silent: true, markInitialResolved: true }); }, delayMs)); }, [refresh]); // Refetch PR status when selected remote changes const handleRemoteChange = React.useCallback((remote: GitRemote) => { didUserOverrideRemoteRef.current = true; setSelectedRemote((prev) => (prev?.name === remote.name ? prev : remote)); }, []); React.useEffect(() => { if (!github?.prStatus || !canShow || remotes.length <= 1) { return; } if (didUserOverrideRemoteRef.current) { return; } if (status?.pr) { return; } const probeKey = `${snapshotKey}::${selectedRemote?.name ?? ''}`; if (autoRemoteProbeDoneRef.current.has(probeKey)) { return; } autoRemoteProbeDoneRef.current.add(probeKey); const candidates = rankRemotesForAutoSelect(remotes, trackingBranch) .filter((remote) => remote.name !== selectedRemote?.name); if (candidates.length === 0) { return; } let cancelled = false; const run = async () => { for (const candidate of candidates) { if (cancelled) { return; } try { const next = await github.prStatus(directory, branch, candidate.name); if (!next?.pr) { continue; } if (cancelled) { return; } setSelectedRemote((prev) => (prev?.name === candidate.name ? prev : candidate)); return; } catch { // ignore } } }; void run(); return () => { cancelled = true; }; }, [branch, canShow, directory, github, remotes, selectedRemote?.name, snapshotKey, status?.pr, trackingBranch]); React.useEffect(() => { ensurePrStatusEntry(prStatusKey); setPrStatusParams(prStatusKey, { directory, branch, remoteName: selectedRemote?.name ?? null, canShow, github, githubAuthChecked, githubConnected: githubAuthStatus?.connected ?? null, }); }, [ branch, canShow, directory, ensurePrStatusEntry, github, githubAuthChecked, githubAuthStatus?.connected, prStatusKey, selectedRemote?.name, setPrStatusParams, ]); React.useEffect(() => { startPrStatusWatching(prStatusKey); return () => { stopPrStatusWatching(prStatusKey); }; }, [prStatusKey, startPrStatusWatching, stopPrStatusWatching]); React.useEffect(() => { const snapshot = pullRequestDraftSnapshots.get(snapshotKey) ?? null; setTitle(snapshot?.title ?? branchToTitle(branch)); setBody(snapshot?.body ?? ''); setDraft(snapshot?.draft ?? false); setTargetBaseBranch(snapshot?.targetBaseBranch ? normalizeBranchRef(snapshot.targetBaseBranch) : normalizeBranchRef(baseBranch)); const nextRemote = pickInitialPrRemote(remotes, { selectedRemoteName: snapshot?.selectedRemoteName, trackingBranch, }); setSelectedRemote((prev) => (prev?.name === nextRemote?.name ? prev : nextRemote)); }, [baseBranch, branch, remotes, snapshotKey, trackingBranch]); React.useEffect(() => { void refresh({ markInitialResolved: true }); }, [prStatusKey, refresh]); React.useEffect(() => { if (!canShow || !selectedRemote?.name) { return; } void refresh({ force: true, silent: true, markInitialResolved: true }); }, [canShow, refresh, selectedRemote?.name]); React.useEffect(() => { const resolvedRemoteName = status?.resolvedRemoteName?.trim(); if (!resolvedRemoteName || didUserOverrideRemoteRef.current) { return; } const resolvedRemote = remotes.find((candidate) => candidate.name === resolvedRemoteName); if (!resolvedRemote) { return; } setSelectedRemote((prev) => (prev?.name === resolvedRemote.name ? prev : resolvedRemote)); }, [remotes, status?.resolvedRemoteName]); React.useEffect(() => { const isTerminal = status?.pr?.state === 'closed' || status?.pr?.state === 'merged'; const lastRefreshAt = statusEntry?.lastRefreshAt ?? 0; const isStale = Date.now() - lastRefreshAt > 60_000; const shouldRefresh = !isTerminal && isStale; const onFocus = () => { if (shouldRefresh) { void refresh({ force: true, silent: true }); } }; const onVisibility = () => { if (document.visibilityState === 'visible') { if (shouldRefresh) { void refresh({ force: true, silent: true }); } } }; window.addEventListener('focus', onFocus); document.addEventListener('visibilitychange', onVisibility); return () => { window.removeEventListener('focus', onFocus); document.removeEventListener('visibilitychange', onVisibility); }; }, [refresh, status?.pr?.state, statusEntry?.lastRefreshAt]); React.useEffect(() => { if (githubAuthChecked && githubAuthStatus?.connected === false) { void refresh({ force: true, silent: true, markInitialResolved: true }); } }, [githubAuthChecked, githubAuthStatus, refresh]); 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(() => { const pendingActionRefreshTimers = pendingActionRefreshTimersRef.current; return () => { pendingActionRefreshTimers.forEach((timerId) => { window.clearTimeout(timerId); }); pendingActionRefreshTimersRef.current = []; }; }, []); const generateDescription = React.useCallback(async () => { if (isGenerating) return; if (!directory) return; setIsGenerating(true); try { const payload: { base: string; head: string; context?: string; files?: string[] } = { base: targetBaseBranch, head: branch, }; if (additionalContext) { payload.context = additionalContext; } const generated = await generatePullRequestDescription(directory, payload); 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); } }, [additionalContext, branch, directory, isGenerating, 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'); updatePrStatus(prStatusKey, (prev) => (prev ? { ...prev, pr } : prev)); await refresh({ force: true }); scheduleActionRefresh(); } 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, prStatusKey, refresh, scheduleActionRefresh, selectedRemote, targetBaseBranch, title, updatePrStatus]); 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 }); scheduleActionRefresh(); } 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, scheduleActionRefresh]); 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 }); scheduleActionRefresh(); } 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, scheduleActionRefresh]); 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, }); updatePrStatus(prStatusKey, (prev) => (prev ? { ...prev, pr: { ...(prev.pr ?? pr), ...updated, }, } : prev)); setIsEditingPr(false); toast.success('PR updated'); await refresh({ force: true }); scheduleActionRefresh(); } 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, prStatusKey, refresh, scheduleActionRefresh, updatePrStatus]); 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-transparent 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} />