feat: add GitHub PR list and context APIs

Add PRs list API with pagination
Provide PR context API with optional diff
Integrate PR picker in session UI
This commit is contained in:
Bohdan Triapitsyn
2026-01-24 01:57:35 +02:00
parent 57f00be773
commit f370ea5d8c
14 changed files with 2507 additions and 79 deletions
@@ -25,9 +25,9 @@ import { useConfigStore } from '@/stores/useConfigStore';
import { useMessageStore } from '@/stores/messageStore';
import { useContextStore } from '@/stores/contextStore';
import { opencodeClient } from '@/lib/opencode/client';
import { createWorktreeSessionForBranch } from '@/lib/worktreeSessionCreator';
import { createBranch } from '@/lib/gitApi';
import type { GitHubIssue, GitHubIssueComment, GitHubIssuesListResult } from '@/lib/api/types';
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
import type { GitHubIssue, GitHubIssueComment, GitHubIssuesListResult, GitHubIssueSummary } from '@/lib/api/types';
const parseIssueNumber = (value: string): number | null => {
const trimmed = value.trim();
@@ -48,15 +48,6 @@ const parseIssueNumber = (value: string): number | null => {
return null;
};
const sanitizeSlug = (value: string): string => {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^[-_]+|[-_]+$/g, '')
.slice(0, 80);
};
const buildIssueContextText = (args: {
repo: GitHubIssuesListResult['repo'] | undefined;
issue: GitHubIssue;
@@ -86,8 +77,12 @@ export function GitHubIssuePickerDialog({
const [query, setQuery] = React.useState('');
const [createInWorktree, setCreateInWorktree] = React.useState(false);
const [result, setResult] = React.useState<GitHubIssuesListResult | null>(null);
const [issues, setIssues] = React.useState<GitHubIssueSummary[]>([]);
const [page, setPage] = React.useState(1);
const [hasMore, setHasMore] = React.useState(false);
const [startingIssueNumber, setStartingIssueNumber] = React.useState<number | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const refresh = React.useCallback(async () => {
@@ -105,8 +100,11 @@ export function GitHubIssuePickerDialog({
setIsLoading(true);
setError(null);
try {
const next = await github.issuesList(projectDirectory);
const next = await github.issuesList(projectDirectory, { page: 1 });
setResult(next);
setIssues(next.issues ?? []);
setPage(next.page ?? 1);
setHasMore(Boolean(next.hasMore));
if (next.connected === false) {
setError(null);
}
@@ -117,6 +115,28 @@ export function GitHubIssuePickerDialog({
}
}, [github, projectDirectory]);
const loadMore = React.useCallback(async () => {
if (!projectDirectory) return;
if (!github?.issuesList) return;
if (isLoadingMore || isLoading) return;
if (!hasMore) return;
setIsLoadingMore(true);
try {
const nextPage = page + 1;
const next = await github.issuesList(projectDirectory, { page: nextPage });
setResult(next);
setIssues((prev) => [...prev, ...(next.issues ?? [])]);
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 issues', { description: message });
} finally {
setIsLoadingMore(false);
}
}, [github, hasMore, isLoading, isLoadingMore, page, projectDirectory]);
React.useEffect(() => {
if (!open) {
setQuery('');
@@ -124,13 +144,15 @@ export function GitHubIssuePickerDialog({
setStartingIssueNumber(null);
setError(null);
setResult(null);
setIssues([]);
setPage(1);
setHasMore(false);
setIsLoading(false);
return;
}
void refresh();
}, [open, refresh]);
const issues = React.useMemo(() => result?.issues ?? [], [result?.issues]);
const connected = Boolean(result?.connected);
const repoUrl = result?.repo?.url ?? null;
@@ -207,29 +229,6 @@ export function GitHubIssuePickerDialog({
return settingsDefaultVariant;
}, []);
const buildUniqueIssueBranchName = React.useCallback(
async (issue: GitHubIssue) => {
const titleSlug = sanitizeSlug(issue.title);
const base = titleSlug ? `issue-${issue.number}-${titleSlug}` : `issue-${issue.number}`;
const startPoint = baseBranch && baseBranch !== 'HEAD' ? baseBranch : undefined;
for (let attempt = 0; attempt < 6; attempt += 1) {
const candidate = attempt === 0 ? base : `${base}-${attempt + 1}`;
try {
const created = await createBranch(projectDirectory || '', candidate, startPoint);
if (created?.success) {
return candidate;
}
} catch {
// try next
}
}
throw new Error('Failed to create issue branch');
},
[baseBranch, projectDirectory]
);
const startSession = React.useCallback(async (issueNumber: number) => {
if (!projectDirectory) {
toast.error('No active project');
@@ -270,8 +269,12 @@ export function GitHubIssuePickerDialog({
const sessionId = await (async () => {
if (createInWorktree) {
const branchName = await buildUniqueIssueBranchName(issue);
const created = await createWorktreeSessionForBranch(projectDirectory, branchName);
const preferred = `issue-${issue.number}-${generateBranchSlug()}`;
const created = await createWorktreeSessionForNewBranch(
projectDirectory,
preferred,
baseBranch || 'main'
);
if (!created?.id) {
throw new Error('Failed to create worktree session');
}
@@ -350,8 +353,42 @@ export function GitHubIssuePickerDialog({
}
}
const promptText =
'Review this GitHub issue. Summarize requirements + unknowns, ask clarifying questions, gather any needed code context, then propose a plan and next actions. Do not implement until I confirm.';
const visiblePromptText = 'Review this issue using the provided issue context: title, body, labels, assignees, comments, metadata.';
const instructionsText = `Review this issue using the provided issue context.
Process:
- First classify the issue type (bug / feature request / question/support / refactor / ops) and state it as: Type: <one label>.
- Gather any needed repository context (code, config, docs) to validate assumptions.
- After gathering, if anything is still unclear or cannot be verified, do not speculate—state whats missing and ask targeted questions.
Output rules:
- Compact output; pick ONE template below and omit the others.
- No emojis. No code snippets. No fenced blocks.
- Short inline code identifiers allowed.
- Reference evidence with file paths and line ranges when applicable; if exact lines arent available, cite the file and say “approx” + why.
- Keep the entire response under ~300 words.
Templates (choose one):
Bug:
- Summary (1-2 sentences)
- Likely cause (max 2)
- Repro/diagnostics needed (max 3)
- Fix approach (max 4 steps)
- Verification (max 3)
Feature:
- Summary (1-2 sentences)
- Requirements (max 4)
- Unknowns/questions (max 4)
- Proposed plan (max 5 steps)
- Verification (max 3)
Question/Support:
- Summary (1-2 sentences)
- Answer/guidance (max 6 lines)
- Missing info (max 4)
Do not implement changes until I confirm; end with: “Next actions: <1 sentence>”.`;
const contextText = buildIssueContextText({ repo: issueRes.repo, issue, comments });
void opencodeClient.sendMessage({
@@ -360,8 +397,11 @@ export function GitHubIssuePickerDialog({
modelID,
agent: agentName,
variant,
text: promptText,
additionalParts: [{ text: contextText, synthetic: true }],
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 issue context', {
@@ -376,17 +416,7 @@ export function GitHubIssuePickerDialog({
} finally {
setStartingIssueNumber(null);
}
}, [
buildUniqueIssueBranchName,
createInWorktree,
github,
onOpenChange,
projectDirectory,
resolveDefaultAgentName,
resolveDefaultModelSelection,
resolveDefaultVariant,
startingIssueNumber,
]);
}, [createInWorktree, github, onOpenChange, projectDirectory, baseBranch, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, startingIssueNumber]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -493,6 +523,29 @@ export function GitHubIssuePickerDialog({
</div>
</div>
))}
{hasMore && connected && projectDirectory && github ? (
<div className="py-2 flex justify-center">
<button
type="button"
onClick={() => void loadMore()}
disabled={isLoadingMore || Boolean(startingIssueNumber)}
className={cn(
'typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-4',
(isLoadingMore || Boolean(startingIssueNumber)) && '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">
@@ -0,0 +1,640 @@
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 { opencodeClient } from '@/lib/opencode/client';
import { createWorktreeSessionForNewBranchExact } from '@/lib/worktreeSessionCreator';
import { gitFetch } from '@/lib/gitApi';
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)}`;
};
export function GitHubPullRequestPickerDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { github } = useRuntimeAPIs();
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 [error, setError] = React.useState<string | null>(null);
const refresh = React.useCallback(async () => {
if (!projectDirectory) {
setResult(null);
setError('No active project');
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, 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);
return;
}
void refresh();
}, [open, refresh]);
const connected = Boolean(result?.connected);
const repoUrl = result?.repo?.url ?? null;
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 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 preferredBranch = pr.head;
const session = await createWorktreeSessionForNewBranchExact(projectDirectory, preferredBranch, 'FETCH_HEAD');
if (!session?.id) {
throw new Error('Failed to create PR worktree session');
}
return { id: session.id };
}, [projectDirectory]);
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 });
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 its 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 whats 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 arent 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">GitHub not connected.</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) => (
<div
key={pr.number}
className={cn(
'group flex items-center gap-2 py-1.5 hover:bg-muted/30 rounded transition-colors cursor-pointer',
startingNumber === pr.number && 'bg-muted/30'
)}
onClick={() => void startSession(pr.number)}
>
<span className="typography-meta text-muted-foreground w-12 text-right flex-shrink-0">#{pr.number}</span>
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">{pr.title}</p>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
{startingNumber === pr.number ? (
<RiLoader4Line className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className="hidden group-hover:flex h-5 w-5 items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
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>
);
}
@@ -39,6 +39,7 @@ import {
RiFileCopyLine,
RiFolderAddLine,
RiGitBranchLine,
RiGitPullRequestLine,
RiGitRepositoryLine,
RiLinkUnlinkM,
@@ -65,6 +66,7 @@ import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
import { isVSCodeRuntime } from '@/lib/desktop';
import { BranchPickerDialog } from './BranchPickerDialog';
import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog';
import { GitHubPullRequestPickerDialog } from './GitHubPullRequestPickerDialog';
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents';
@@ -143,6 +145,7 @@ interface SortableProjectItemProps {
onNewWorktreeSession?: () => void;
onOpenBranchPicker?: () => void;
onNewSessionFromGitHubIssue?: () => void;
onNewSessionFromGitHubPR?: () => void;
onOpenMultiRunLauncher: () => void;
onClose: () => void;
sentinelRef: (el: HTMLDivElement | null) => void;
@@ -168,6 +171,7 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
onNewWorktreeSession,
onOpenBranchPicker,
onNewSessionFromGitHubIssue,
onNewSessionFromGitHubPR,
onOpenMultiRunLauncher,
onClose,
sentinelRef,
@@ -284,6 +288,12 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
New session from GitHub issue
</DropdownMenuItem>
)}
{isRepo && !hideDirectoryControls && onNewSessionFromGitHubPR && (
<DropdownMenuItem onClick={onNewSessionFromGitHubPR}>
<RiGitPullRequestLine className="mr-1.5 h-4 w-4" />
New session from GitHub PR
</DropdownMenuItem>
)}
{isRepo && !hideDirectoryControls && (
<DropdownMenuItem onClick={onOpenMultiRunLauncher}>
<ArrowsMerge className="mr-1.5 h-4 w-4" />
@@ -411,6 +421,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
const [branchPickerOpen, setBranchPickerOpen] = React.useState(false);
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
const [pullRequestPickerOpen, setPullRequestPickerOpen] = React.useState(false);
const [activeDragId, setActiveDragId] = React.useState<string | null>(null);
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
@@ -1653,6 +1664,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}
setIssuePickerOpen(true);
}}
onNewSessionFromGitHubPR={() => {
if (projectKey !== activeProjectId) {
setActiveProject(projectKey);
}
setActiveMainTab('chat');
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
setPullRequestPickerOpen(true);
}}
onOpenMultiRunLauncher={() => {
if (projectKey !== activeProjectId) {
setActiveProject(projectKey);
@@ -1699,6 +1720,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
open={issuePickerOpen}
onOpenChange={setIssuePickerOpen}
/>
<GitHubPullRequestPickerDialog
open={pullRequestPickerOpen}
onOpenChange={setPullRequestPickerOpen}
/>
</div>
);
};
+62 -1
View File
@@ -522,6 +522,62 @@ export type GitHubPullRequest = {
mergeableState?: string | null;
};
export type GitHubPullRequestHeadRepo = {
owner: string;
repo: string;
url: string;
cloneUrl?: string;
};
export type GitHubPullRequestSummary = GitHubPullRequest & {
author?: GitHubUserSummary | null;
body?: string;
createdAt?: string;
updatedAt?: string;
headLabel?: string;
headRepo?: GitHubPullRequestHeadRepo | null;
};
export type GitHubPullRequestFile = {
filename: string;
status?: string;
additions?: number;
deletions?: number;
changes?: number;
patch?: string;
};
export type GitHubPullRequestReviewComment = {
id: number;
url: string;
body: string;
author?: GitHubUserSummary | null;
path?: string;
line?: number | null;
position?: number | null;
createdAt?: string;
updatedAt?: string;
};
export type GitHubPullRequestsListResult = {
connected: boolean;
repo?: GitHubRepoRef | null;
prs?: GitHubPullRequestSummary[];
page?: number;
hasMore?: boolean;
};
export type GitHubPullRequestContextResult = {
connected: boolean;
repo?: GitHubRepoRef | null;
pr?: GitHubPullRequestSummary | null;
issueComments?: GitHubIssueComment[];
reviewComments?: GitHubPullRequestReviewComment[];
files?: GitHubPullRequestFile[];
diff?: string;
checks?: GitHubChecksSummary | null;
};
export type GitHubPullRequestStatus = {
connected: boolean;
repo?: GitHubRepoRef | null;
@@ -594,6 +650,8 @@ export type GitHubIssuesListResult = {
connected: boolean;
repo?: GitHubRepoRef | null;
issues?: GitHubIssueSummary[];
page?: number;
hasMore?: boolean;
};
export type GitHubIssueGetResult = {
@@ -640,7 +698,10 @@ export interface GitHubAPI {
prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult>;
prReady(payload: GitHubPullRequestReadyInput): Promise<GitHubPullRequestReadyResult>;
issuesList(directory: string): Promise<GitHubIssuesListResult>;
prsList(directory: string, options?: { page?: number }): Promise<GitHubPullRequestsListResult>;
prContext(directory: string, number: number, options?: { includeDiff?: boolean }): Promise<GitHubPullRequestContextResult>;
issuesList(directory: string, options?: { page?: number }): Promise<GitHubIssuesListResult>;
issueGet(directory: string, number: number): Promise<GitHubIssueGetResult>;
issueComments(directory: string, number: number): Promise<GitHubIssueCommentsResult>;
}
@@ -438,3 +438,197 @@ export async function createWorktreeSessionForBranch(
isCreatingWorktreeSession = false;
}
}
/**
* Create a worktree session for a new branch (created at startPoint).
* This avoids checking out the branch in the main worktree.
*/
export async function createWorktreeSessionForNewBranch(
projectDirectory: string,
preferredBranchName: string,
startPoint: string,
options?: { allowSuffix?: boolean }
): Promise<{ id: string; branch: string } | null> {
if (isCreatingWorktreeSession) {
return null;
}
let isGitRepo = false;
try {
isGitRepo = await checkIsGitRepository(projectDirectory);
} catch {
// ignore
}
if (!isGitRepo) {
toast.error('Not a Git repository', {
description: 'Worktrees can only be created in Git repositories.',
});
return null;
}
isCreatingWorktreeSession = true;
startConfigUpdate('Creating worktree session...');
try {
const start = startPoint?.trim() || 'HEAD';
const base = preferredBranchName?.trim();
if (!base) {
throw new Error('Branch name is required');
}
let lastError: unknown = null;
const allowSuffix = options?.allowSuffix !== false;
const maxAttempts = allowSuffix ? 6 : 1;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const candidate = attempt === 0 ? base : `${base}-${attempt + 1}`;
try {
const worktreeSlug = sanitizeWorktreeSlug(candidate);
const metadata = await createWorktree({
projectDirectory,
worktreeSlug,
branch: candidate,
createBranch: true,
startPoint: start,
});
const status = await getWorktreeStatus(metadata.path).catch(() => undefined);
const createdMetadata = status ? { ...metadata, status } : metadata;
const sessionStore = useSessionStore.getState();
const session = await sessionStore.createSession(undefined, metadata.path);
if (!session) {
await removeWorktree({ projectDirectory, path: metadata.path, force: true }).catch(() => undefined);
throw new Error('Could not create a session for the worktree.');
}
const configState = useConfigStore.getState();
sessionStore.initializeNewOpenChamberSession(session.id, configState.agents);
sessionStore.setSessionDirectory(session.id, metadata.path);
sessionStore.setWorktreeMetadata(session.id, createdMetadata);
// Apply default agent/model/variant settings (reuse same logic as createWorktreeSessionForBranch)
try {
const visibleAgents = configState.getVisibleAgents();
let agentName: string | undefined;
if (configState.settingsDefaultAgent) {
const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent);
if (settingsAgent) {
agentName = settingsAgent.name;
}
}
if (!agentName) {
agentName =
visibleAgents.find((agent) => agent.name === 'build')?.name ||
visibleAgents[0]?.name;
}
if (agentName) {
configState.setAgent(agentName);
useContextStore.getState().saveSessionAgentSelection(session.id, agentName);
const settingsDefaultModel = configState.settingsDefaultModel;
if (settingsDefaultModel) {
const parts = settingsDefaultModel.split('/');
if (parts.length === 2) {
const [providerId, modelId] = parts;
const modelMetadata = configState.getModelMetadata(providerId, modelId);
if (modelMetadata) {
useContextStore.getState().saveSessionModelSelection(session.id, providerId, modelId);
useContextStore.getState().saveAgentModelForSession(session.id, agentName, providerId, modelId);
const settingsDefaultVariant = configState.settingsDefaultVariant;
if (settingsDefaultVariant) {
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 && Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) {
configState.setCurrentVariant(settingsDefaultVariant);
useContextStore
.getState()
.saveAgentModelVariantForSession(session.id, agentName, providerId, modelId, settingsDefaultVariant);
}
}
}
}
}
}
} catch {
// ignore
}
useDirectoryStore.getState().setDirectory(metadata.path, { showOverlay: false });
try {
await sessionStore.loadSessions();
} catch {
// ignore
}
// Get and run setup commands
const setupCommands = await getWorktreeSetupCommands(projectDirectory);
const commandsToRun = setupCommands.filter((cmd) => cmd.trim().length > 0);
if (commandsToRun.length > 0) {
toast.success('Worktree created', {
description: `Branch: ${candidate}. Running ${commandsToRun.length} setup command${commandsToRun.length === 1 ? '' : 's'}...`,
});
// Run setup commands in background
runWorktreeSetupCommands(metadata.path, projectDirectory, commandsToRun)
.then((result) => {
if (result.success) {
toast.success('Setup commands completed', {
description: `All ${result.results.length} command${result.results.length === 1 ? '' : 's'} succeeded.`,
});
} else {
const failed = result.results.filter((r) => !r.success);
const succeeded = result.results.filter((r) => r.success);
toast.error('Setup commands failed', {
description:
`${failed.length} of ${result.results.length} command${result.results.length === 1 ? '' : 's'} failed.` +
(succeeded.length > 0 ? ` ${succeeded.length} succeeded.` : ''),
});
}
})
.catch(() => {
toast.error('Setup commands failed', {
description: 'Could not execute setup commands.',
});
});
} else {
toast.success('Worktree created', {
description: `Branch: ${candidate}`,
});
}
return { id: session.id, branch: candidate };
} catch (error) {
lastError = error;
}
}
const message = lastError instanceof Error ? lastError.message : 'Failed to create worktree session';
toast.error('Failed to create worktree', {
description: message,
});
return null;
} finally {
finishConfigUpdate();
isCreatingWorktreeSession = false;
}
}
/**
* Same as createWorktreeSessionForNewBranch, but does NOT suffix the branch name.
* Use when the worktree must be created on an exact branch name (e.g. PR head ref).
*/
export async function createWorktreeSessionForNewBranchExact(
projectDirectory: string,
branchName: string,
startPoint: string
): Promise<{ id: string; branch: string } | null> {
return createWorktreeSessionForNewBranch(projectDirectory, branchName, startPoint, { allowSuffix: false });
}