import React from 'react'; import { cn } from '@/lib/utils'; import { toast } from '@/components/ui'; import { Checkbox } from '@/components/ui/checkbox'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; 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 { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible'; import { generatePullRequestDescription } from '@/lib/gitApi'; import { openExternalUrl } from '@/lib/url'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useDeviceInfo } from '@/lib/device'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; import { Icon } from "@/components/icon/Icon"; import { useUIStore } from '@/stores/useUIStore'; import { useWalkthroughStore } from '@/stores/useWalkthroughStore'; import { WALKTHROUGH_ACTION_CLASS } from '@/components/views/walkthrough/walkthroughAction'; import { isVSCodeRuntime } from '@/lib/desktop'; import { formatDateTimeForPreference } from '@/lib/timeFormat'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useInlineCommentDraftStore, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; import { getPrContextKey, usePrContextStore } from '@/stores/usePrContextStore'; import { summarizeCheckRuns } from '@/lib/githubChecks'; import type { GitHubPullRequest, GitHubCheckRun, GitHubAPI, GitHubPullRequestStatus, GitRemote, } from '@/lib/api/types'; import { useI18n } from '@/lib/i18n'; type MergeMethod = 'merge' | 'squash' | 'rebase'; type PrSegment = 'overview' | 'checks' | 'comments'; const PR_CHECKS_AUTO_REFRESH_MS = 35_000; const formatElapsedDuration = (startISO?: string, endISO?: string, now?: number): string | null => { if (!startISO) return null; const start = Date.parse(startISO); if (!Number.isFinite(start)) return null; const end = endISO ? Date.parse(endISO) : (now ?? Date.now()); if (!Number.isFinite(end) || end <= start) return null; const totalMinutes = Math.floor((end - start) / 60_000); if (totalMinutes < 1) return '<1m'; if (totalMinutes < 60) return `${totalMinutes}m`; const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; }; const isFailedConclusion = (conclusion?: string | null): boolean => { const normalized = typeof conclusion === 'string' ? conclusion.toLowerCase() : ''; return Boolean(normalized) && !['success', 'neutral', 'skipped'].includes(normalized); }; type DetectedUpstream = { owner: string; repo: string; url: string; defaultBranch?: string; defaultBranchSha?: string | null; remoteName?: string | null }; 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; activeSegment?: PrSegment; }; 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; }; const pullRequestDraftSnapshots = new Map(); const openExternal = openExternalUrl; function useDetectedUpstreamRepo(directory: string, github: GitHubAPI | undefined) { const [detectedUpstream, setDetectedUpstream] = React.useState(null); const [upstreamBranches, setUpstreamBranches] = React.useState([]); const attemptedDirectoryRef = React.useRef(null); React.useEffect(() => { setDetectedUpstream(null); setUpstreamBranches([]); }, [directory]); React.useEffect(() => { if (!directory || !github?.repoUpstream || attemptedDirectoryRef.current === directory) { return; } attemptedDirectoryRef.current = directory; let cancelled = false; void (async () => { try { const result = await github.repoUpstream(directory); if (cancelled || !result?.isFork || !result.upstream) { return; } setDetectedUpstream(result.upstream); if (!github.repoBranches) { return; } try { const branches = await github.repoBranches(result.upstream.owner, result.upstream.repo); if (!cancelled) { setUpstreamBranches(branches); } } catch { // Silently fail - branch list is best-effort. } } catch { // Silently fail - upstream detection is best-effort. } })(); return () => { cancelled = true; }; }, [directory, github]); return { detectedUpstream, upstreamBranches }; } export const PullRequestSection: React.FC<{ directory: string; branch: string; baseBranch: string; trackingBranch?: string; remotes?: GitRemote[]; remoteBranches?: string[]; onGeneratedDescription?: () => void; }> = ({ directory, branch, baseBranch, trackingBranch, remotes = [], remoteBranches = [], onGeneratedDescription }) => { const { t } = useI18n(); const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); 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 currentSessionId = useSessionUIStore((state) => state.currentSessionId); const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); const { isMobile, hasTouchInput, screenWidth } = useDeviceInfo(); const openContextSurface = useUIStore((state) => state.openContextSurface); const requestWalkthroughSource = useWalkthroughStore((state) => state.requestSource); // Mirrors the rail's gating: the surface is not available on mobile widths or // in VS Code, so neither is its entry point. const showWalkthroughAction = !isMobile && screenWidth >= 768 && !isVSCodeRuntime(); 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 [useDetectedUpstream, setUseDetectedUpstream] = React.useState(false); const { detectedUpstream, upstreamBranches } = useDetectedUpstreamRepo(directory, github); React.useEffect(() => { setUseDetectedUpstream(false); }, [directory]); const hasUpstreamRemote = remotes.some((r) => r.name === 'upstream'); const isFork = hasUpstreamRemote || detectedUpstream !== null; const canShow = Boolean(directory && branch && baseBranch && (branch !== baseBranch || isFork)); const prStatusKey = React.useMemo( () => getGitHubPrStatusKey(directory, branch, selectedRemote?.name ?? null), [directory, branch, selectedRemote?.name], ); 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 = useDetectedUpstream ? null : (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); } // When using detected upstream, include all upstream repo branches if (useDetectedUpstream) { for (const b of upstreamBranches) { if (b && b !== 'HEAD') { unique.add(b); } } } 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, upstreamBranches, useDetectedUpstream]); // 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 [activeSegment, setActiveSegmentState] = React.useState(() => initialSnapshot?.activeSegment ?? 'overview'); const [expandedCheckStepKeys, setExpandedCheckStepKeys] = React.useState>(new Set()); const [expandedCheckRunKeys, setExpandedCheckRunKeys] = React.useState>(new Set()); 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([]); // Auto-enable detected upstream when there's no explicit upstream remote React.useEffect(() => { if (detectedUpstream && !hasUpstreamRemote) { setUseDetectedUpstream(true); } }, [detectedUpstream, hasUpstreamRemote]); // Set target base branch to upstream's default branch when using detected upstream React.useEffect(() => { if (useDetectedUpstream && detectedUpstream?.defaultBranch) { setTargetBaseBranch(detectedUpstream.defaultBranch); } }, [useDetectedUpstream, detectedUpstream?.defaultBranch]); const pr = status?.pr ?? null; // A closed/merged PR is the branch's history, not its live status: it still // deserves to be shown (you just merged it), but the branch is free again, so // the panel offers creating the next PR instead of a read-only detail view. const isHistoricalPr = pr?.state === 'merged' || pr?.state === 'closed'; const livePr = isHistoricalPr ? null : pr; const prContextKey = livePr ? getPrContextKey(directory, livePr.number) : null; const prContextEntry = usePrContextStore((state) => (prContextKey ? state.entries[prContextKey] : undefined)); const ensurePrContext = usePrContextStore((state) => state.ensure); const prContext = prContextEntry?.result ?? null; const isLoadingPrContext = prContextEntry?.isLoading ?? false; const setActiveSegment = React.useCallback((segment: PrSegment) => { setActiveSegmentState(segment); const snapshot = pullRequestDraftSnapshots.get(snapshotKey); if (snapshot) { pullRequestDraftSnapshots.set(snapshotKey, { ...snapshot, activeSegment: segment }); } }, [snapshotKey]); // Load the context the active segment needs; checks include details. React.useEffect(() => { if (!livePr || !github?.prContext || activeSegment === 'overview') { return; } void ensurePrContext(github, directory, livePr.number, { includeCheckDetails: activeSegment === 'checks', sourceRepo: status?.repo ?? null, }); }, [activeSegment, directory, ensurePrContext, github, livePr, status?.repo]); const checks = status?.checks ?? null; const checksArePending = (checks?.pending ?? 0) > 0; // The detailed run list (pulls/context) and the status aggregate (pr/status) // come from different endpoints with different cache ages. The run list is // the fresher, richer source whenever we have it — derive the aggregate from // it and push it into the status store so every consumer (header, badges, // git-view chip) shows the same numbers as the visible runs. const contextCheckRuns = prContext?.checkRuns ?? null; const contextFetchedAt = prContext?.fetchedAt; React.useEffect(() => { if (!contextCheckRuns || contextCheckRuns.length === 0) { return; } const derived = summarizeCheckRuns(contextCheckRuns); updatePrStatus(prStatusKey, (previous) => { if (!previous?.pr) { return previous; } // Never let older context data regress a fresher status snapshot. if (typeof contextFetchedAt === 'number' && typeof previous.fetchedAt === 'number' && contextFetchedAt < previous.fetchedAt) { return previous; } const current = previous.checks; const unchanged = current && current.state === derived.state && current.total === derived.total && current.success === derived.success && current.failure === derived.failure && current.pending === derived.pending && current.inProgress === derived.inProgress && current.queued === derived.queued && current.startedAt === derived.startedAt; if (unchanged) { return previous; } return { ...previous, checks: derived, // Adopt the context's freshness so a later stale status response // (older server stamp) is rejected by the store's freshness guard. ...(typeof contextFetchedAt === 'number' ? { fetchedAt: contextFetchedAt } : {}), }; }); }, [contextCheckRuns, contextFetchedAt, prStatusKey, updatePrStatus]); // While checks run and the checks segment is visible, keep the detailed // run list fresh; the shared context store dedupes against other callers. React.useEffect(() => { if (activeSegment !== 'checks' || !checksArePending || !pr || !github?.prContext) { return; } const intervalId = window.setInterval(() => { void ensurePrContext(github, directory, pr.number, { includeCheckDetails: true, sourceRepo: status?.repo ?? null, force: true, }); }, PR_CHECKS_AUTO_REFRESH_MS); return () => window.clearInterval(intervalId); }, [activeSegment, checksArePending, directory, ensurePrContext, github, pr, status?.repo]); // Coarse clock for "running for Nm" labels; only ticks while checks run. const [nowTick, setNowTick] = React.useState(() => Date.now()); React.useEffect(() => { if (!checksArePending) { return; } setNowTick(Date.now()); const intervalId = window.setInterval(() => setNowTick(Date.now()), 30_000); return () => window.clearInterval(intervalId); }, [checksArePending]); 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 ensurePrContext(github, directory, pr.number, { sourceRepo: status?.repo ?? null }) .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, ensurePrContext, github, pr, prStatusKey, status?.repo, 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 formatTimestamp = React.useCallback((value?: string) => { if (!value) return ''; const ts = Date.parse(value); if (!Number.isFinite(ts)) { return value; } return formatDateTimeForPreference(ts, timeFormatPreference, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', }); }, [timeFormatPreference]); 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 = (prContext?.issueComments ?? []).map((comment) => ({ id: `issue-${comment.id}`, body: comment.body || '', authorName: comment.author?.name || comment.author?.login || t('gitView.pr.comments.unknownAuthor'), authorLogin: comment.author?.login || null, avatarUrl: comment.author?.avatarUrl || null, createdAt: comment.createdAt, context: t('gitView.pr.comments.generalContext'), path: null as string | null, line: null as number | null, })); const review = (prContext?.reviewComments ?? []).map((comment) => ({ id: `review-${comment.id}`, body: comment.body || '', authorName: comment.author?.name || comment.author?.login || t('gitView.pr.comments.unknownAuthor'), authorLogin: comment.author?.login || null, avatarUrl: comment.author?.avatarUrl || null, createdAt: comment.createdAt, context: t('gitView.pr.comments.reviewContext'), 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; }, [prContext, t]); // PR comments/checks are pinned as inline-comment drafts above the chat // input (like terminal selections), not sent as an immediate message — the // user decides how to prompt and when to send. const resolveDraftTarget = React.useCallback((): InlineCommentDraftTarget | null => { // Same convention as diff/file comments: a new-session draft pins context // under the 'draft' key, which the composer adopts when the session is // created — starting a fresh session from a PR comment is a valid flow. const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null); if (!sessionKey) { toast.error(t('gitView.pr.toast.noActiveSession'), { description: t('gitView.pr.toast.noActiveSessionDescription') }); return null; } return { directory, sessionKey }; }, [currentSessionId, directory, newSessionDraftOpen, t]); const attachCommentDraft = React.useCallback((target: InlineCommentDraftTarget, comment: TimelineCommentItem) => { const authorLabel = comment.authorLogin ? `@${comment.authorLogin}` : comment.authorName; const location = comment.path ? ` · ${comment.path}${comment.line ? `:${comment.line}` : ''}` : ''; useInlineCommentDraftStore.getState().addDraft(target, { source: 'pr-comment', fileLabel: `PR #${pr?.number ?? ''} ${authorLabel}${location}`, startLine: comment.line ?? 0, endLine: comment.line ?? 0, code: comment.body, language: 'markdown', text: '', }); }, [pr?.number]); const renderCheckRunSummary = React.useCallback((run: GitHubCheckRun, options?: { hideHeader?: boolean }) => { 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 (
{!options?.hideHeader ? (
{run.name}
{appName ? `${appName} · ${statusText}` : statusText}
) : null} {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 ? (
{t('gitView.pr.checks.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' ?
{t('gitView.pr.checks.stepLabel')}: {step.number}
: null} {step.status ?
{t('gitView.pr.checks.statusLabel')}: {step.status}
: null} {step.conclusion ?
{t('gitView.pr.checks.conclusionLabel')}: {step.conclusion}
: null} {step.startedAt ?
{t('gitView.pr.checks.startedLabel')}: {formatTimestamp(step.startedAt)}
: null} {step.completedAt ?
{t('gitView.pr.checks.completedLabel')}: {formatTimestamp(step.completedAt)}
: null}
); })}
) : null}
); }, [expandedCheckStepKeys, formatTimestamp, t]); const [isAttachingChecks, setIsAttachingChecks] = React.useState(false); const [isAttachingComments, setIsAttachingComments] = React.useState(false); const sendFailedChecksToChat = React.useCallback(async () => { if (!github?.prContext) { toast.error(t('gitView.pr.toast.githubApiUnavailable')); return; } if (!directory || !pr) return; const target = resolveDraftTarget(); if (!target) { return; } setIsAttachingChecks(true); try { const context = await ensurePrContext(github, directory, pr.number, { includeCheckDetails: true, sourceRepo: status?.repo ?? null }); if (!context) { toast.error(t('gitView.pr.toast.loadChecksFailed')); return; } const runs = context.checkRuns ?? []; const failed = runs.filter((r) => isFailedConclusion(r.conclusion)); if (failed.length === 0) { toast.message(t('gitView.pr.toast.noFailedChecks')); return; } const draftStore = useInlineCommentDraftStore.getState(); for (const run of failed) { const annotations = (run.annotations ?? []).map((annotation) => [ [annotation.level, annotation.title].filter(Boolean).join(' '), annotation.path ? `${annotation.path}${typeof annotation.startLine === 'number' ? `:${annotation.startLine}` : ''}` : null, annotation.message, annotation.rawDetails, ].filter(Boolean).join('\n')); const failedSteps = (run.job?.steps ?? []) .filter((step) => isFailedConclusion(step.conclusion)) .map((step) => `step ${step.number ?? '?'}: ${step.name} → ${step.conclusion}`); const payload = [ `check: ${run.job?.workflowName ? `${run.job.workflowName} / ${run.name}` : run.name}`, `status: ${run.status ?? 'unknown'} / ${run.conclusion ?? 'unknown'}`, run.detailsUrl ? `url: ${run.detailsUrl}` : null, run.output?.title ? `title: ${run.output.title}` : null, run.output?.summary ? `summary:\n${run.output.summary}` : null, failedSteps.length > 0 ? `failed steps:\n${failedSteps.join('\n')}` : null, annotations.length > 0 ? `annotations:\n${annotations.join('\n---\n')}` : null, ].filter(Boolean).join('\n\n'); draftStore.addDraft(target, { source: 'pr-check', fileLabel: `PR #${pr.number} · ${run.name}`, startLine: 0, endLine: 0, code: payload, language: 'text', text: '', }); } } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error(t('gitView.pr.toast.loadChecksFailed'), { description: message }); } finally { setIsAttachingChecks(false); } }, [directory, ensurePrContext, github, pr, resolveDraftTarget, status?.repo, t]); const sendCommentsToChat = React.useCallback(async () => { if (!github?.prContext) { toast.error(t('gitView.pr.toast.githubApiUnavailable')); return; } if (!directory || !pr) return; const target = resolveDraftTarget(); if (!target) { return; } setIsAttachingComments(true); try { const context = await ensurePrContext(github, directory, pr.number, { sourceRepo: status?.repo ?? null }); if (!context) { toast.error(t('gitView.pr.toast.loadPrCommentsFailed')); return; } if (timelineComments.length === 0) { toast.message(t('gitView.pr.toast.noPrComments')); return; } for (const comment of timelineComments) { attachCommentDraft(target, comment); } } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error(t('gitView.pr.toast.loadPrCommentsFailed'), { description: message }); } finally { setIsAttachingComments(false); } }, [attachCommentDraft, directory, ensurePrContext, github, pr, resolveDraftTarget, status?.repo, t, timelineComments]); const sendSingleCommentToChat = React.useCallback(async (comment: TimelineCommentItem) => { const target = resolveDraftTarget(); if (!target) { return; } attachCommentDraft(target, comment); }, [attachCommentDraft, resolveDraftTarget]); 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]); 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(() => { // Coming back to the app is the moment a PR is most likely to have changed // elsewhere — including a merged one being replaced by a newer open PR — so // staleness is read from the store when the event fires, not captured here. const refreshWhenStale = () => { const lastRefreshAt = useGitHubPrStatusStore.getState().entries[prStatusKey]?.lastRefreshAt ?? 0; if (Date.now() - lastRefreshAt > 60_000) { void refresh({ force: true, silent: true }); } }; const onVisibility = () => { if (document.visibilityState !== 'visible') { return; } refreshWhenStale(); }; window.addEventListener('focus', refreshWhenStale); document.addEventListener('visibilitychange', onVisibility); return () => { window.removeEventListener('focus', refreshWhenStale); document.removeEventListener('visibilitychange', onVisibility); }; }, [prStatusKey, refresh]); 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, activeSegment, }); }, [snapshotKey, title, body, draft, additionalContext, targetBaseBranch, selectedRemote?.name, directory, branch, activeSegment]); 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 { // For cross-repo PRs, use the upstream's default branch SHA for the commit range. // Using a bare branch name like "main" would resolve to the local ref, making // "git log main..main" a no-op. The SHA points to the actual upstream commit. const baseRef = (useDetectedUpstream && detectedUpstream?.defaultBranchSha) ? detectedUpstream.defaultBranchSha : targetBaseBranch; const payload: { base: string; head: string; context?: string; files?: string[] } = { base: baseRef, 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(t('gitView.pr.toast.generateDescriptionFailed'), { description: message }); } finally { setIsGenerating(false); } }, [additionalContext, branch, detectedUpstream?.defaultBranchSha, directory, isGenerating, onGeneratedDescription, targetBaseBranch, t, useDetectedUpstream]); const createPr = React.useCallback(async () => { if (!github?.prCreate) { toast.error(t('gitView.pr.toast.githubApiUnavailable')); return; } const trimmedTitle = title.trim(); if (!trimmedTitle) { toast.error(t('gitView.pr.toast.titleRequired')); return; } const trimmedBase = targetBaseBranch.trim(); if (!trimmedBase) { toast.error(t('gitView.pr.toast.baseBranchRequired')); return; } if (!useDetectedUpstream && trimmedBase === branch) { toast.error(t('gitView.pr.toast.baseMustDifferFromHead')); return; } setIsCreating(true); try { const trackingRemoteName = getTrackingRemoteName(trackingBranch); const usingDetectedUpstream = useDetectedUpstream && detectedUpstream; const pr = await github.prCreate({ directory, title: trimmedTitle, head: branch, base: trimmedBase, ...(body.trim() ? { body } : {}), draft, ...(usingDetectedUpstream ? { targetRepo: { owner: detectedUpstream.owner, repo: detectedUpstream.repo }, headRemote: 'origin' } : { ...(selectedRemote ? { remote: selectedRemote.name } : {}), ...(trackingRemoteName && trackingRemoteName !== selectedRemote?.name ? { headRemote: trackingRemoteName } : {}), }), }); toast.success(t('gitView.pr.toast.prCreated')); 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(t('gitView.pr.toast.createPrFailed'), { description: message }); } finally { setIsCreating(false); } }, [body, branch, detectedUpstream, directory, draft, github, prStatusKey, refresh, scheduleActionRefresh, selectedRemote, targetBaseBranch, title, trackingBranch, updatePrStatus, useDetectedUpstream, t]); const mergePr = React.useCallback(async (pr: GitHubPullRequest) => { if (!github?.prMerge) { toast.error(t('gitView.pr.toast.githubApiUnavailable')); return; } setIsMerging(true); try { const result = await github.prMerge({ directory, number: pr.number, method: mergeMethod }); if (result.merged) { toast.success(t('gitView.pr.toast.prMerged')); } else { toast.message(t('gitView.pr.toast.prNotMerged'), { description: result.message || t('gitView.pr.notMergeable') }); } await refresh({ force: true }); scheduleActionRefresh(); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error(t('gitView.pr.toast.mergeFailed'), { description: message }); if (pr.url) { void openExternal(pr.url); } } finally { setIsMerging(false); } }, [directory, github, mergeMethod, refresh, scheduleActionRefresh, t]); const markReady = React.useCallback(async (pr: GitHubPullRequest) => { if (!github?.prReady) { toast.error(t('gitView.pr.toast.githubApiUnavailable')); return; } setIsMarkingReady(true); try { await github.prReady({ directory, number: pr.number }); toast.success(t('gitView.pr.toast.markedReady')); await refresh({ force: true }); scheduleActionRefresh(); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error(t('gitView.pr.toast.markReadyFailed'), { description: message }); if (pr.url) { void openExternal(pr.url); } } finally { setIsMarkingReady(false); } }, [directory, github, refresh, scheduleActionRefresh, t]); const updatePr = React.useCallback(async (pr: GitHubPullRequest) => { if (!github?.prUpdate) { toast.error(t('gitView.pr.toast.githubApiUnavailable')); return; } const trimmedTitle = editTitle.trim(); if (!trimmedTitle) { toast.error(t('gitView.pr.toast.titleRequired')); 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(t('gitView.pr.toast.prUpdated')); await refresh({ force: true }); scheduleActionRefresh(); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error(t('gitView.pr.toast.updatePrFailed'), { description: message }); } finally { setIsUpdating(false); } }, [directory, editBody, editTitle, github, prStatusKey, refresh, scheduleActionRefresh, updatePrStatus, t]); if (!canShow) { return (
{t('gitView.pullRequest.title')}
{t('gitView.pullRequest.availableOnFeatureBranches')}
); } const originRepoUrl = status?.repo?.url || null; const repoUrl = (useDetectedUpstream && detectedUpstream?.url) ? detectedUpstream.url : originRepoUrl; 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 prStateIconName = prVisualState === 'draft' ? 'git-pr-draft' : prVisualState === 'merged' ? 'git-merge' : prVisualState === 'closed' ? 'git-close-pull-request' : 'git-pull-request'; const prStatusText = pr ? [ `${pr.state}${pr.draft ? ' (draft)' : ''}`, pr.mergeable === false ? t('gitView.pr.notMergeable') : null, pr.state === 'open' && typeof pr.mergeableState === 'string' && pr.mergeableState && pr.mergeableState !== 'unknown' ? pr.mergeableState : null, ].filter(Boolean).join(' · ') : ''; const checksText = checks ? checks.total > 0 ? `${checks.success}/${checks.total} ${t('gitView.pr.checks.label')}` : `${checks.state} ${t('gitView.pr.checks.label')}` : ''; const containerClassName = 'border-0 bg-transparent rounded-none'; const headerClassName = 'px-0 py-3 border-b border-border/40 flex flex-col gap-1'; const bodyClassName = 'flex flex-col gap-3 py-3'; return (
{pr ? ( ) : ( )}

{t('gitView.pullRequest.title')}

{pr ? ( #{pr.number} ) : null}
{isLoading ? : null}

{t('gitView.pr.actions.refresh')}

{pr ? (
{prStatusText} {checks ? ( {checksText} ) : null} {trackingBranch && selectedRemote && trackingBranch.split('/')[0] !== selectedRemote.name ? ( {trackingBranch.split('/')[0]} → {selectedRemote.name} ) : null}
{showWalkthroughAction ? ( ) : null} {canMerge && pr.draft && pr.state === 'open' ? (

{t('gitView.pr.actions.markReady')}

) : null} {canMerge ? ( <>

{t('gitView.pr.actions.mergePr')}

) : null}
) : null}
{shouldShowConnectionNotice ? (
{t('gitView.pr.githubNotConnected')}
) : null} {error ? (
{t('gitView.pr.statusUnavailable')}
{error}
{repoUrl ? ( ) : null}
) : null} {!pr && !isInitialStatusResolved && !error && !shouldShowConnectionNotice ? (
{t('gitView.pr.checkingStatus')}
) : pr && !isHistoricalPr ? (
0 ? `${t('gitView.pr.segment.checks')} ${checks.success}/${checks.total}` : t('gitView.pr.segment.checks'), icon: checks ? : undefined, }, { id: 'comments', label: prContext ? `${t('gitView.pr.segment.comments')} ${(prContext.issueComments?.length ?? 0) + (prContext.reviewComments?.length ?? 0)}` : t('gitView.pr.segment.comments'), }, ]} activeId={activeSegment} onSelect={(segmentId) => setActiveSegment(segmentId as PrSegment)} layoutMode="fit" variant="active-pill" activePillButtonClassName="h-7" />
{activeSegment === 'overview' ? (
{canMerge && pr.draft ? (
{t('gitView.pr.draftMustBeReady')}
) : null} {!canMerge ? (
{t('gitView.pr.noMergePermission')}
) : null}
{isEditingPr ? ( setEditTitle(e.target.value)} placeholder={t('gitView.pr.placeholder.title')} autoCorrect={hasTouchInput ? "on" : "off"} autoCapitalize={hasTouchInput ? "sentences" : "off"} spellCheck={hasTouchInput} /> ) : (
{pr.title}
)}
{pr.state === 'open' ? (
{isEditingPr ? ( <>

{t('gitView.pr.actions.cancelEditing')}

{t('gitView.pr.actions.savePr')}

) : (

{t('gitView.pr.actions.editPr')}

)}
) : null}
{isEditingPr ? (