- Use git reset --hard HEAD by default when startPoint is not provided or is HEAD - Preserve resetting to the provided startPoint when a concrete value is supplied
830 lines
32 KiB
TypeScript
830 lines
32 KiB
TypeScript
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<GitHubPullRequestsListResult | null>(null);
|
||
const [prs, setPrs] = React.useState<GitHubPullRequestSummary[]>([]);
|
||
const [page, setPage] = React.useState(1);
|
||
const [hasMore, setHasMore] = React.useState(false);
|
||
const [startingNumber, setStartingNumber] = React.useState<number | null>(null);
|
||
const [isLoading, setIsLoading] = React.useState(false);
|
||
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
|
||
const [existingBranchHeads, setExistingBranchHeads] = React.useState<Map<string, boolean>>(new Map());
|
||
const [error, setError] = React.useState<string | null>(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<string, unknown>) => (m as { id?: string }).id === modelID) as
|
||
| { variants?: Record<string, unknown> }
|
||
| 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:
|
||
- <issue> — <brief why> — <file:line-range> — Action: <one-line action>
|
||
Nice-to-have:
|
||
- <issue> — <brief why> — <file:line-range> — Action: <one-line 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 (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
|
||
<DialogHeader className="flex-shrink-0">
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<RiGitPullRequestLine className="h-5 w-5" />
|
||
New Session From GitHub PR
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
Seeds a new session with hidden PR context (title/body/comments/files/checks).
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="relative mt-2">
|
||
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
placeholder="Search by title or #123, or paste PR URL"
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
className="pl-9 w-full"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto mt-2">
|
||
{!projectDirectory ? (
|
||
<div className="text-center text-muted-foreground py-8">No active project selected.</div>
|
||
) : null}
|
||
|
||
{!github ? (
|
||
<div className="text-center text-muted-foreground py-8">GitHub runtime API unavailable.</div>
|
||
) : null}
|
||
|
||
{isLoading ? (
|
||
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
|
||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||
Loading pull requests...
|
||
</div>
|
||
) : null}
|
||
|
||
{connected === false ? (
|
||
<div className="text-center text-muted-foreground py-8 space-y-3">
|
||
<div>GitHub not connected. Connect your GitHub account in settings.</div>
|
||
<div className="flex justify-center">
|
||
<Button variant="outline" size="sm" onClick={openGitHubSettings}>
|
||
Open settings
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{error ? (
|
||
<div className="text-center text-muted-foreground py-8 break-words">{error}</div>
|
||
) : null}
|
||
|
||
{directNumber && projectDirectory && github && connected ? (
|
||
<div
|
||
className={cn(
|
||
'group flex items-center gap-2 py-1.5 hover:bg-muted/30 rounded transition-colors cursor-pointer',
|
||
startingNumber === directNumber && 'bg-muted/30'
|
||
)}
|
||
onClick={() => void startSession(directNumber)}
|
||
>
|
||
<span className="typography-meta text-muted-foreground w-5 text-right flex-shrink-0">#</span>
|
||
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
|
||
Use PR #{directNumber}
|
||
</p>
|
||
<div className="flex-shrink-0 h-5 flex items-center mr-2">
|
||
{startingNumber === directNumber ? (
|
||
<RiLoader4Line className="h-4 w-4 animate-spin text-muted-foreground" />
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{filtered.length === 0 && !isLoading && connected && github && projectDirectory ? (
|
||
<div className="text-center text-muted-foreground py-8">{query ? 'No PRs found' : 'No open PRs found'}</div>
|
||
) : null}
|
||
|
||
{filtered.map((pr) => {
|
||
const disabledByWorktree = isPrDisabledForWorktree(pr);
|
||
|
||
return (
|
||
<div
|
||
key={pr.number}
|
||
className={cn(
|
||
'group flex items-start gap-2 py-1.5 rounded transition-colors',
|
||
startingNumber === pr.number && 'bg-muted/30',
|
||
disabledByWorktree
|
||
? 'opacity-50 cursor-not-allowed'
|
||
: 'hover:bg-muted/30 cursor-pointer'
|
||
)}
|
||
onClick={() => {
|
||
if (disabledByWorktree) return;
|
||
void startSession(pr.number);
|
||
}}
|
||
>
|
||
<span className="typography-meta text-muted-foreground w-12 text-right flex-shrink-0 pt-0.5">#{pr.number}</span>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="typography-small text-foreground truncate ml-0.5">{pr.title}</p>
|
||
{createInWorktree && disabledByWorktree ? (
|
||
<p className="typography-micro text-muted-foreground mt-0.5 ml-0.5">
|
||
PR worktree disabled: local branch exists ({pr.head})
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
<div className="flex-shrink-0 h-5 flex items-center mr-2 pt-0.5">
|
||
{startingNumber === pr.number ? (
|
||
<RiLoader4Line className="h-4 w-4 animate-spin text-muted-foreground" />
|
||
) : (
|
||
<a
|
||
href={pr.url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className={cn(
|
||
'hidden group-hover:flex h-5 w-5 items-center justify-center text-muted-foreground hover:text-foreground transition-colors',
|
||
disabledByWorktree && 'pointer-events-none'
|
||
)}
|
||
onClick={(e) => e.stopPropagation()}
|
||
aria-label="Open in GitHub"
|
||
>
|
||
<RiExternalLinkLine className="h-4 w-4" />
|
||
</a>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{hasMore && connected && projectDirectory && github ? (
|
||
<div className="py-2 flex justify-center">
|
||
<button
|
||
type="button"
|
||
onClick={() => void loadMore()}
|
||
disabled={isLoadingMore || Boolean(startingNumber)}
|
||
className={cn(
|
||
'typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-4',
|
||
(isLoadingMore || Boolean(startingNumber)) && 'opacity-50 cursor-not-allowed hover:text-muted-foreground'
|
||
)}
|
||
>
|
||
{isLoadingMore ? (
|
||
<span className="inline-flex items-center gap-2">
|
||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||
Loading...
|
||
</span>
|
||
) : (
|
||
'Load more'
|
||
)}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
|
||
<p className="typography-meta text-muted-foreground font-medium mb-2">Actions</p>
|
||
<div className="flex items-center gap-2">
|
||
<div
|
||
className="flex items-center gap-2 cursor-pointer"
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-pressed={createInWorktree}
|
||
onClick={() => setCreateInWorktree((v) => !v)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === ' ' || e.key === 'Enter') {
|
||
e.preventDefault();
|
||
setCreateInWorktree((v) => !v);
|
||
}
|
||
}}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setCreateInWorktree((v) => !v);
|
||
}}
|
||
aria-label="Toggle worktree"
|
||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||
>
|
||
{createInWorktree ? (
|
||
<RiCheckboxLine className="h-4 w-4 text-primary" />
|
||
) : (
|
||
<RiCheckboxBlankLine className="h-4 w-4" />
|
||
)}
|
||
</button>
|
||
<span className="typography-meta text-muted-foreground">Create session in PR worktree</span>
|
||
</div>
|
||
|
||
|
||
<div
|
||
className="flex items-center gap-2 cursor-pointer"
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-pressed={includeDiff}
|
||
onClick={() => setIncludeDiff((v) => !v)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === ' ' || e.key === 'Enter') {
|
||
e.preventDefault();
|
||
setIncludeDiff((v) => !v);
|
||
}
|
||
}}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setIncludeDiff((v) => !v);
|
||
}}
|
||
aria-label="Toggle diff"
|
||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||
>
|
||
{includeDiff ? (
|
||
<RiCheckboxLine className="h-4 w-4 text-primary" />
|
||
) : (
|
||
<RiCheckboxBlankLine className="h-4 w-4" />
|
||
)}
|
||
</button>
|
||
<span className="typography-meta text-muted-foreground">Include full diff</span>
|
||
</div>
|
||
|
||
<div className="flex-1" />
|
||
{repoUrl ? (
|
||
<Button variant="outline" size="sm" asChild>
|
||
<a href={repoUrl} target="_blank" rel="noopener noreferrer">
|
||
<RiExternalLinkLine className="size-4" />
|
||
Open Repo
|
||
</a>
|
||
</Button>
|
||
) : null}
|
||
<Button variant="outline" size="sm" onClick={refresh} disabled={isLoading || Boolean(startingNumber)}>
|
||
Refresh
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|