import React from 'react'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { toast } from '@/components/ui'; import { RiCheckboxBlankLine, RiCheckboxLine, RiExternalLinkLine, RiGitPullRequestLine, RiLoader4Line, RiSearchLine, } from '@remixicon/react'; import { cn } from '@/lib/utils'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useSessionStore } from '@/stores/useSessionStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useMessageStore } from '@/stores/messageStore'; import { useContextStore } from '@/stores/contextStore'; import { useUIStore } from '@/stores/useUIStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { opencodeClient } from '@/lib/opencode/client'; import { createWorktreeSessionForNewBranchExact } from '@/lib/worktreeSessionCreator'; import { gitFetch } from '@/lib/gitApi'; import { execCommand, execCommands } from '@/lib/execCommands'; import type { GitHubPullRequestContextResult, GitHubPullRequestSummary, GitHubPullRequestsListResult } from '@/lib/api/types'; const parsePullRequestNumber = (value: string): number | null => { const trimmed = value.trim(); if (!trimmed) return null; const urlMatch = trimmed.match(/\/pull\/(\d+)(?:\b|\/|$)/i); if (urlMatch) { const parsed = Number(urlMatch[1]); return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } const hashMatch = trimmed.match(/^#?(\d+)$/); if (hashMatch) { const parsed = Number(hashMatch[1]); return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } return null; }; const buildPullRequestContextText = (payload: GitHubPullRequestContextResult) => { return `GitHub pull request context (JSON)\n${JSON.stringify(payload, null, 2)}`; }; const sanitizeGitRemoteName = (value: string): string => { return value .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 64); }; export function GitHubPullRequestPickerDialog({ open, onOpenChange, }: { open: boolean; onOpenChange: (open: boolean) => void; }) { 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 activeProject = useProjectsStore((state) => state.getActiveProject()); const projectDirectory = activeProject?.path ?? null; const [query, setQuery] = React.useState(''); const [createInWorktree, setCreateInWorktree] = React.useState(false); const [includeDiff, setIncludeDiff] = React.useState(false); const [result, setResult] = React.useState(null); const [prs, setPrs] = React.useState([]); const [page, setPage] = React.useState(1); const [hasMore, setHasMore] = React.useState(false); const [startingNumber, setStartingNumber] = React.useState(null); const [isLoading, setIsLoading] = React.useState(false); const [isLoadingMore, setIsLoadingMore] = React.useState(false); const [existingBranchHeads, setExistingBranchHeads] = React.useState>(new Map()); const [error, setError] = React.useState(null); const refresh = React.useCallback(async () => { if (!projectDirectory) { setResult(null); setError('No active project'); return; } if (githubAuthChecked && githubAuthStatus?.connected === false) { setResult({ connected: false }); setPrs([]); setHasMore(false); setPage(1); setError(null); return; } if (!github?.prsList) { setResult(null); setError('GitHub runtime API unavailable'); return; } setIsLoading(true); setError(null); try { const next = await github.prsList(projectDirectory, { page: 1 }); setResult(next); setPrs(next.prs ?? []); setPage(next.page ?? 1); setHasMore(Boolean(next.hasMore)); if (next.connected === false) { setError(null); } } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setIsLoading(false); } }, [github, githubAuthChecked, githubAuthStatus, projectDirectory]); const loadMore = React.useCallback(async () => { if (!projectDirectory) return; if (!github?.prsList) return; if (isLoadingMore || isLoading) return; if (!hasMore) return; setIsLoadingMore(true); try { const nextPage = page + 1; const next = await github.prsList(projectDirectory, { page: nextPage }); setResult(next); setPrs((prev) => [...prev, ...(next.prs ?? [])]); setPage(next.page ?? nextPage); setHasMore(Boolean(next.hasMore)); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to load more PRs', { description: message }); } finally { setIsLoadingMore(false); } }, [github, hasMore, isLoading, isLoadingMore, page, projectDirectory]); React.useEffect(() => { if (!open) { setQuery(''); setCreateInWorktree(false); setIncludeDiff(false); setResult(null); setPrs([]); setPage(1); setHasMore(false); setStartingNumber(null); setIsLoading(false); setError(null); setExistingBranchHeads(new Map()); return; } void refresh(); }, [open, refresh]); const checkLocalBranchExists = React.useCallback(async (heads: string[]) => { if (!projectDirectory) return; const unique = Array.from(new Set(heads.map((h) => (h || '').trim()).filter(Boolean))); if (unique.length === 0) return; // Only check unknown heads (optimistic enable; disable after result arrives). const unknown = unique.filter((h) => !existingBranchHeads.has(h)); if (unknown.length === 0) return; // optimistic UI: no spinner; disable once results arrive { // Avoid shell wrappers; rely on exit code only. const commands = unknown.map((h) => `git show-ref --verify --quiet ${JSON.stringify(`refs/heads/${h}`)}`); const res = await execCommands(commands, projectDirectory); setExistingBranchHeads((prev) => { const next = new Map(prev); for (let i = 0; i < unknown.length; i += 1) { const head = unknown[i]; next.set(head, Boolean(res.results[i]?.success)); } return next; }); } }, [projectDirectory, existingBranchHeads]); React.useEffect(() => { if (!open) return; if (!projectDirectory) return; if (!createInWorktree) return; void checkLocalBranchExists(prs.map((pr) => pr.head)); }, [open, projectDirectory, createInWorktree, prs, checkLocalBranchExists]); React.useEffect(() => { if (!open) return; if (githubAuthChecked && githubAuthStatus?.connected === false) { setResult({ connected: false }); setPrs([]); setHasMore(false); setPage(1); setError(null); } }, [githubAuthChecked, githubAuthStatus, open]); const connected = githubAuthChecked ? result?.connected !== false : true; const repoUrl = result?.repo?.url ?? null; const openGitHubSettings = React.useCallback(() => { setSidebarSection('settings'); setSettingsDialogOpen(true); }, [setSettingsDialogOpen, setSidebarSection]); const filtered = React.useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return prs; return prs.filter((pr) => { if (String(pr.number) === q.replace(/^#/, '')) return true; return pr.title.toLowerCase().includes(q); }); }, [prs, query]); const isPrDisabledForWorktree = React.useCallback((pr: GitHubPullRequestSummary): boolean => { if (!createInWorktree) return false; const head = pr.head?.trim(); if (!head) return true; const exists = existingBranchHeads.get(head); // Optimistic: treat unknown as enabled. return exists === true; }, [createInWorktree, existingBranchHeads]); const directNumber = React.useMemo(() => parsePullRequestNumber(query), [query]); const resolveDefaultAgentName = React.useCallback((): string | undefined => { const configState = useConfigStore.getState(); const visibleAgents = configState.getVisibleAgents(); if (configState.settingsDefaultAgent) { const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent); if (settingsAgent) { return settingsAgent.name; } } return visibleAgents.find((agent) => agent.name === 'build')?.name || visibleAgents[0]?.name; }, []); const resolveDefaultModelSelection = React.useCallback((): { providerID: string; modelID: string } | null => { const configState = useConfigStore.getState(); const settingsDefaultModel = configState.settingsDefaultModel; if (!settingsDefaultModel) return null; const parts = settingsDefaultModel.split('/'); if (parts.length !== 2) return null; const [providerID, modelID] = parts; if (!providerID || !modelID) return null; const modelMetadata = configState.getModelMetadata(providerID, modelID); if (!modelMetadata) return null; return { providerID, modelID }; }, []); const resolveDefaultVariant = React.useCallback((providerID: string, modelID: string): string | undefined => { const configState = useConfigStore.getState(); const settingsDefaultVariant = configState.settingsDefaultVariant; if (!settingsDefaultVariant) return undefined; const provider = configState.providers.find((p) => p.id === providerID); const model = provider?.models.find((m: Record) => (m as { id?: string }).id === modelID) as | { variants?: Record } | undefined; const variants = model?.variants; if (!variants) return undefined; if (!Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) return undefined; return settingsDefaultVariant; }, []); const createPrWorktreeSession = React.useCallback(async ( baseRepo: GitHubPullRequestsListResult['repo'] | undefined, pr: GitHubPullRequestSummary, ): Promise<{ id: string } | null> => { if (!projectDirectory) return null; const headRef = pr.head; const headRepo = pr.headRepo; if (!headRef) { throw new Error('PR head ref missing'); } const isFork = Boolean( headRepo?.owner && headRepo?.repo && baseRepo?.owner && baseRepo?.repo && (headRepo.owner !== baseRepo.owner || headRepo.repo !== baseRepo.repo) ); const fetchRemote = isFork ? (headRepo?.cloneUrl || headRepo?.url || '') : 'origin'; if (!fetchRemote) { throw new Error('PR head remote URL missing'); } const fetchRef = `refs/heads/${headRef}`; const fetchResult = await gitFetch(projectDirectory, { remote: fetchRemote, branch: fetchRef }); if (!fetchResult?.success) { throw new Error('Failed to fetch PR head'); } const headCommitish = pr.headSha?.trim() || (await execCommand('git rev-parse FETCH_HEAD', projectDirectory)).stdout?.trim() || ''; if (!headCommitish) { throw new Error('PR head commit not resolvable'); } const preferredBranch = pr.head; // Prevent clobbering/removing an existing local branch when using PR worktree mode. if (existingBranchHeads.get(preferredBranch) === true) { throw new Error(`Local branch already exists: ${preferredBranch}`); } const session = await createWorktreeSessionForNewBranchExact(projectDirectory, preferredBranch, headCommitish, { kind: 'pr', }); if (!session?.id) { throw new Error('Failed to create PR worktree session'); } const meta = useSessionStore.getState().worktreeMetadata.get(session.id); const worktreeDir = meta?.path; if (!worktreeDir) { throw new Error('Worktree directory not found'); } // Switch the new worktree to the PR branch and delete the SDK-created opencode/* branch immediately. // This makes the worktree directly operate on the PR branch. const commands: string[] = [ // Create local branch from the fetched PR head commit. `git -C ${JSON.stringify(worktreeDir)} switch -c ${JSON.stringify(preferredBranch)} ${JSON.stringify(headCommitish)}`, ]; const originalBranch = (meta?.branch || session.branch || '').replace(/^refs\/heads\//, '').trim(); if (meta?.kind === 'pr' && originalBranch && originalBranch.startsWith('opencode/')) { commands.push(`git -C ${JSON.stringify(projectDirectory)} branch -D ${JSON.stringify(originalBranch)}`); } const result = await execCommands(commands, projectDirectory); if (!result.success) { const failed = result.results.find((r) => !r.success); throw new Error(failed?.stderr || failed?.stdout || 'Failed to switch worktree to PR branch'); } // Best-effort: set upstream for PR branch (without pushing). try { const remoteName = isFork ? sanitizeGitRemoteName(`pr-${headRepo?.owner || 'fork'}-${headRepo?.repo || ''}`) : 'origin'; const remoteUrl = isFork ? (headRepo?.cloneUrl || headRepo?.url || '') : ''; const fetchRefspec = `+refs/heads/${preferredBranch}:refs/remotes/${remoteName}/${preferredBranch}`; const upstreamCommands: string[] = []; if (isFork && remoteUrl) { upstreamCommands.push( `git -C ${JSON.stringify(projectDirectory)} remote add ${JSON.stringify(remoteName)} ${JSON.stringify(remoteUrl)} 2>/dev/null || git -C ${JSON.stringify(projectDirectory)} remote set-url ${JSON.stringify(remoteName)} ${JSON.stringify(remoteUrl)}` ); } upstreamCommands.push( `git -C ${JSON.stringify(projectDirectory)} fetch ${JSON.stringify(remoteName)} ${JSON.stringify(fetchRefspec)}` ); upstreamCommands.push( `git -C ${JSON.stringify(worktreeDir)} branch --set-upstream-to=${JSON.stringify(`${remoteName}/${preferredBranch}`)} ${JSON.stringify(preferredBranch)}` ); const upstreamResult = await execCommands(upstreamCommands, projectDirectory); if (!upstreamResult.success) { const failed = upstreamResult.results.find((r) => !r.success); toast.message('PR upstream not set', { description: failed?.stderr || failed?.stdout || 'Configure remote manually if needed.' }); } } catch { toast.message('PR upstream not set', { description: 'Configure remote manually if needed.' }); } // Update stored metadata for better UX + reintegration target. useSessionStore.getState().setWorktreeMetadata(session.id, { ...(meta || { path: worktreeDir, projectDirectory, branch: preferredBranch, label: preferredBranch }), path: worktreeDir, projectDirectory, branch: preferredBranch, label: preferredBranch, createdFromBranch: pr.base, kind: 'pr' as const, }); return { id: session.id }; }, [projectDirectory, existingBranchHeads]); const startSession = React.useCallback(async (number: number) => { if (!projectDirectory) { toast.error('No active project'); return; } if (!github?.prContext) { toast.error('GitHub runtime API unavailable'); return; } if (startingNumber) return; setStartingNumber(number); try { const prContext = await github.prContext(projectDirectory, number, { includeDiff, includeCheckDetails: false }); if (prContext.connected === false) { toast.error('GitHub not connected'); return; } if (!prContext.repo) { toast.error('Repo not resolvable', { description: 'origin remote must be a GitHub URL' }); return; } if (!prContext.pr) { toast.error('PR not found'); return; } const pr = prContext.pr; const sessionTitle = `#${pr.number} ${pr.title}`.trim(); const sessionId = await (async () => { if (createInWorktree) { try { const worktreeSession = await createPrWorktreeSession(prContext.repo, pr); return worktreeSession?.id || null; } catch (e) { const msg = e instanceof Error ? e.message : String(e); toast.error('PR worktree failed', { description: msg }); // fall back to normal session } } const session = await useSessionStore.getState().createSession(sessionTitle, projectDirectory, null); return session?.id || null; })(); if (!sessionId) { throw new Error('Failed to create session'); } void useSessionStore.getState().updateSessionTitle(sessionId, sessionTitle).catch(() => undefined); try { useSessionStore.getState().initializeNewOpenChamberSession(sessionId, useConfigStore.getState().agents); } catch { // ignore } onOpenChange(false); const configState = useConfigStore.getState(); const lastUsedProvider = useMessageStore.getState().lastUsedProvider; const defaultModel = resolveDefaultModelSelection(); const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID; const modelID = defaultModel?.modelID || configState.currentModelId || lastUsedProvider?.modelID; const agentName = resolveDefaultAgentName() || configState.currentAgentName || undefined; if (!providerID || !modelID) { toast.error('No model selected'); return; } const variant = resolveDefaultVariant(providerID, modelID); try { useContextStore.getState().saveSessionModelSelection(sessionId, providerID, modelID); } catch { // ignore } if (agentName) { try { configState.setAgent(agentName); } catch { // ignore } try { useContextStore.getState().saveSessionAgentSelection(sessionId, agentName); } catch { // ignore } try { useContextStore.getState().saveAgentModelForSession(sessionId, agentName, providerID, modelID); } catch { // ignore } if (variant !== undefined) { try { configState.setCurrentVariant(variant); } catch { // ignore } try { useContextStore.getState().saveAgentModelVariantForSession(sessionId, agentName, providerID, modelID, variant); } catch { // ignore } } } const visiblePromptText = 'Review this pull request using the provided PR context: description, comments, files, diff, checks.'; const instructionsText = `Before reporting issues: - First identify the PR intent (what it’s trying to achieve) from title/body/diff, then evaluate whether the implementation matches that intent; call out missing pieces, incorrect behavior vs intent, and scope creep. - Gather any needed repository context (code, config, docs) to validate assumptions. - No speculation: if something is unclear or cannot be verified, say what’s missing and ask for it instead of guessing. Output rules: - Start with a 1-2 sentence summary. - Provide a single concise PR review comment. - No emojis. No code snippets. No fenced blocks. - Short inline code identifiers allowed, but no snippets or fenced blocks. - Reference evidence with file paths and line ranges (e.g., path/to/file.ts:120-138). If exact lines aren’t available, cite the file and say “approx” + why. - Keep the entire comment under ~300 words. Report: - Must-fix issues (blocking) — brief why and a one-line action each. - Nice-to-have improvements (optional) — brief why and a one-line action each. Quality & safety (general): - Call out correctness risks, edge cases, performance regressions, security/privacy concerns, and backwards-compatibility risks. - Call out missing tests/verification steps and suggest the minimal validation needed. - Note readability/maintainability issues when they materially affect future changes. Applicability (only if relevant): - If changes affect multiple components/targets/environments (e.g., client/server, OSs, deployments), state what is affected vs not, and why. Architecture: - Call out breakages, missing implementations across modules/targets, boundary violations, and cross-cutting concerns (errors, logging/observability, accessibility). Precedence: - If local precedent conflicts with best practices, state it and suggest a follow-up task. Do not implement changes until I confirm; end with a short “Next actions” sentence describing the recommended plan. Format exactly: Must-fix: - — Action: Nice-to-have: - — Action: If no issues, write: Must-fix: - None Nice-to-have: - None`; const contextText = buildPullRequestContextText(prContext); void opencodeClient.sendMessage({ id: sessionId, providerID, modelID, agent: agentName, variant, text: visiblePromptText, additionalParts: [ { text: instructionsText, synthetic: true }, { text: contextText, synthetic: true }, ], }).catch((e) => { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to send PR context', { description: message }); }); toast.success('Session created from PR'); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to start session', { description: message }); } finally { setStartingNumber(null); } }, [ createInWorktree, createPrWorktreeSession, github, includeDiff, onOpenChange, projectDirectory, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, startingNumber, ]); return ( New Session From GitHub PR Seeds a new session with hidden PR context (title/body/comments/files/checks).
setQuery(e.target.value)} className="pl-9 w-full" />
{!projectDirectory ? (
No active project selected.
) : null} {!github ? (
GitHub runtime API unavailable.
) : null} {isLoading ? (
Loading pull requests...
) : null} {connected === false ? (
GitHub not connected. Connect your GitHub account in settings.
) : null} {error ? (
{error}
) : null} {directNumber && projectDirectory && github && connected ? (
void startSession(directNumber)} > #

Use PR #{directNumber}

{startingNumber === directNumber ? ( ) : null}
) : null} {filtered.length === 0 && !isLoading && connected && github && projectDirectory ? (
{query ? 'No PRs found' : 'No open PRs found'}
) : null} {filtered.map((pr) => { const disabledByWorktree = isPrDisabledForWorktree(pr); return (
{ if (disabledByWorktree) return; void startSession(pr.number); }} > #{pr.number}

{pr.title}

{createInWorktree && disabledByWorktree ? (

PR worktree disabled: local branch exists ({pr.head})

) : null}
{startingNumber === pr.number ? ( ) : ( e.stopPropagation()} aria-label="Open in GitHub" > )}
); })} {hasMore && connected && projectDirectory && github ? (
) : null}

Actions

setCreateInWorktree((v) => !v)} onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setCreateInWorktree((v) => !v); } }} > Create session in PR worktree
setIncludeDiff((v) => !v)} onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setIncludeDiff((v) => !v); } }} > Include full diff
{repoUrl ? ( ) : null}
); }