feat: fork-aware issue/PR listing & OpenCode startup loading indicator (#1061)

* Add design spec: OpenCode readiness loading indicator

* Add implementation plan: OpenCode readiness loading indicator

* feat: add useOpenCodeReadiness hook

* feat: add i18n keys for common.loading

* feat: add loading state to ModelSelector

* feat: add loading state to AgentSelector

* feat: add loading state to ModelControls chat selectors

* update package-lock

* feat(github): add shared fork detection utility

* feat(github): make issue listing fork-aware

* feat(github): make PR listing fork-aware

* feat(types): add sourceRepo to issue/PR summary types

* feat(ui): add source badges to GitHub integration dialog

* feat(ui): add source badges to issue/PR picker dialogs

* feat(github): pass headRemote in PR creation for fork support

* feat(ui): add source→target label in PR tab for fork workflows

* fix(github): allow PR section on base branch when upstream remote exists

* fix(github): show PR section on any branch including main for fork→upstream PRs

* fix(github): allow PullRequestSection to render on base branch when upstream remote exists

* feat(github): auto-detect upstream repo for fork→upstream PR creation

- Add GET /api/github/repo/upstream endpoint to discover fork's upstream
- Fix PullRequestSection canShow to allow PR creation on base branch when repo is a fork
- Add virtual upstream target in remote dropdown (no explicit upstream remote needed)
- Add targetRepo parameter to /api/github/pr/create for direct upstream targeting
- Add repoUpstream() API client method and GitHubRepoUpstreamResult type

* feat(github): auto-detect upstream repo for fork→upstream PR creation

- Add GET /api/github/repo/upstream endpoint to discover fork's upstream
- Fix PullRequestSection canShow to allow PR creation on base branch when repo is a fork
- Add virtual upstream target in remote dropdown (no explicit upstream remote needed)
- Add targetRepo parameter to /api/github/pr/create for direct upstream targeting
- Add repoUpstream() API client method and GitHubRepoUpstreamResult type

* fix: complete fork→upstream PR workflow

- Server: return defaultBranch from /api/github/repo/upstream endpoint
- Server: fix cross-repo head ref construction (compare repos, not remote names)
- Server: filterActiveRemoteBranches checks all remotes, not just origin
- UI: set targetBaseBranch to upstream's default branch when using detected upstream
- UI: include all remote branches in base branch dropdown when using detected upstream
- UI: skip base===head check for cross-repo PRs (same branch name on different repos is valid)
- Types: add defaultBranch to GitHubRepoUpstreamResult

* chore: delete superpowers folder

* feat: add (local)/(remote) labels to PR branch display and adapt Repository button to selected remote

* feat: Repository button adapts to selected remote (upstream vs origin)

* fix: complete fork→upstream PR feature gaps

Server:
- Extend /api/github/repo/upstream to return defaultBranchSha and remoteName
- Reuse headRepo result instead of redundant resolveGitHubRepoFromDirectory call
- Return clear error when headRepo is null (invalid GitHub URL)

UI:
- Add upstream's default branch to availableBaseBranches when using detected upstream
- Use upstream's default branch SHA in git log for generate description (fixes 'No commits found in range main...main')
- Show qualified names (owner/repo · branch) in base branch dropdown when using detected upstream

Types:
- Add defaultBranchSha and remoteName to GitHubRepoUpstreamResult

* fix: move detectedUpstream state before availableBaseBranches to fix temporal dead zone

* fix: fetch upstream branches from GitHub API for base branch dropdown

- Add GET /api/github/repo/branches endpoint to fetch branches via Octokit
- Add repoBranches() to GitHub API client and interface
- Fetch upstream branches on detection and store in upstreamBranches state
- Include upstreamBranches in availableBaseBranches when using detected upstream
- Re-add availableBaseBranches memo and auto-correction effect that were lost
- Remove unnecessary qualified names from dropdown (upstream is already selected)

* fix: restore prStatusKey and statusEntry declarations lost during refactor

* fix: cleanly re-apply all fork→upstream PR UI changes

Restored PullRequestSection.tsx from clean base and re-applied:
- Expand detectedUpstream type with defaultBranch, defaultBranchSha, remoteName
- Add upstreamBranches state and fetch on upstream detection
- Include upstream branches in availableBaseBranches when using detected upstream
- Use upstream default branch SHA in generate description (fixes 'No commits found')
- Adapt Repository button URL to selected remote
- Add (local)/(remote)/(upstream) labels to branch display

* fix: move detectedUpstream/upstreamBranches before availableBaseBranches to fix TDZ

* style: add pill badge styling to upstream repo source labels

* fix: don't cache error PR status responses, allow force-bypass of server cache

* fix: resolve PR status cache bugs, stale directory fallback, and upstream re-detection

* fix: keep collapse button visible when scrolling long user messages

- Collapse button now sticks to top of scrollable user message content instead of scrolling away

* fix: checkbox focus ring blends into sidebar background

* fix: polish fork PR follow-ups

* fix: remove user message collapse artifact

* fix: tighten fork PR internals

* fix: check all remotes for fork PR status

* fix: recover sidebar PR status misses

---------

Signed-off-by: Islam Nofl <islamnofl.official@gmail.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Islam Nofl
2026-04-29 12:03:39 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 17650becc0
commit 21253d7fc2
27 changed files with 1042 additions and 296 deletions
@@ -16,6 +16,7 @@ import {
RiGitPullRequestLine,
RiInformationLine,
RiLoader4Line,
RiRefreshLine,
} from '@remixicon/react';
import { toast } from '@/components/ui';
import { Checkbox } from '@/components/ui/checkbox';
@@ -59,6 +60,7 @@ import { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHub
import type {
GitHubPullRequest,
GitHubCheckRun,
GitHubAPI,
GitHubPullRequestContextResult,
GitHubPullRequestStatus,
GitRemote,
@@ -66,6 +68,7 @@ import type {
import { useI18n } from '@/lib/i18n';
type MergeMethod = 'merge' | 'squash' | 'rebase';
type DetectedUpstream = { owner: string; repo: string; url: string; defaultBranch?: string; defaultBranchSha?: string | null; remoteName?: string | null };
const statusColor = (state: string | undefined | null): string => {
switch (state) {
@@ -270,6 +273,56 @@ const pullRequestDraftSnapshots = new Map<string, PullRequestDraftSnapshot>();
const openExternal = openExternalUrl;
function useDetectedUpstreamRepo(directory: string, github: GitHubAPI | undefined) {
const [detectedUpstream, setDetectedUpstream] = React.useState<DetectedUpstream | null>(null);
const [upstreamBranches, setUpstreamBranches] = React.useState<string[]>([]);
const attemptedDirectoryRef = React.useRef<string | null>(null);
React.useEffect(() => {
setDetectedUpstream(null);
setUpstreamBranches([]);
}, [directory]);
React.useEffect(() => {
if (!directory || !github?.repoUpstream || attemptedDirectoryRef.current === directory) {
return;
}
attemptedDirectoryRef.current = directory;
let cancelled = false;
void (async () => {
try {
const result = await github.repoUpstream(directory);
if (cancelled || !result?.isFork || !result.upstream) {
return;
}
setDetectedUpstream(result.upstream);
if (!github.repoBranches) {
return;
}
try {
const branches = await github.repoBranches(result.upstream.owner, result.upstream.repo);
if (!cancelled) {
setUpstreamBranches(branches);
}
} catch {
// Silently fail - branch list is best-effort.
}
} catch {
// Silently fail - upstream detection is best-effort.
}
})();
return () => {
cancelled = true;
};
}, [directory, github]);
return { detectedUpstream, upstreamBranches };
}
export const PullRequestSection: React.FC<{
directory: string;
branch: string;
@@ -339,6 +392,16 @@ export const PullRequestSection: React.FC<{
trackingBranch,
})
);
const [useDetectedUpstream, setUseDetectedUpstream] = React.useState(false);
const { detectedUpstream, upstreamBranches } = useDetectedUpstreamRepo(directory, github);
React.useEffect(() => {
setUseDetectedUpstream(false);
}, [directory]);
const hasUpstreamRemote = remotes.some((r) => r.name === 'upstream');
const isFork = hasUpstreamRemote || detectedUpstream !== null;
const canShow = Boolean(directory && branch && baseBranch && (branch !== baseBranch || isFork));
const prStatusKey = React.useMemo(
() => getGitHubPrStatusKey(directory, branch),
@@ -352,7 +415,7 @@ export const PullRequestSection: React.FC<{
const isInitialStatusResolved = statusEntry?.isInitialStatusResolved ?? false;
const availableBaseBranches = React.useMemo(() => {
const selectedRemoteName = selectedRemote?.name?.trim() || null;
const selectedRemoteName = useDetectedUpstream ? null : (selectedRemote?.name?.trim() || null);
const unique = new Set<string>();
for (const remoteBranch of remoteBranches) {
@@ -363,6 +426,15 @@ export const PullRequestSection: React.FC<{
unique.add(branchName);
}
// When using detected upstream, include all upstream repo branches
if (useDetectedUpstream) {
for (const b of upstreamBranches) {
if (b && b !== 'HEAD') {
unique.add(b);
}
}
}
const defaultBase = normalizeBranchRef(baseBranch);
if (defaultBase && defaultBase !== 'HEAD') {
unique.add(defaultBase);
@@ -374,7 +446,7 @@ export const PullRequestSection: React.FC<{
}
return Array.from(unique).sort((a, b) => a.localeCompare(b));
}, [baseBranch, remoteBranches, selectedRemote?.name, targetBaseBranch]);
}, [baseBranch, remoteBranches, selectedRemote?.name, targetBaseBranch, upstreamBranches, useDetectedUpstream]);
const hasMultipleRemotes = remotes.length > 1;
@@ -432,7 +504,19 @@ export const PullRequestSection: React.FC<{
const autoRemoteProbeDoneRef = React.useRef<Set<string>>(new Set());
const pendingActionRefreshTimersRef = React.useRef<number[]>([]);
const canShow = Boolean(directory && branch && baseBranch && branch !== baseBranch);
// Auto-enable detected upstream when there's no explicit upstream remote
React.useEffect(() => {
if (detectedUpstream && !hasUpstreamRemote) {
setUseDetectedUpstream(true);
}
}, [detectedUpstream, hasUpstreamRemote]);
// Set target base branch to upstream's default branch when using detected upstream
React.useEffect(() => {
if (useDetectedUpstream && detectedUpstream?.defaultBranch) {
setTargetBaseBranch(detectedUpstream.defaultBranch);
}
}, [useDetectedUpstream, detectedUpstream?.defaultBranch]);
const pr = status?.pr ?? null;
const currentPrBodyHydrationKey = pr ? `${directory}#${pr.number}` : null;
@@ -1120,8 +1204,14 @@ export const PullRequestSection: React.FC<{
if (!directory) return;
setIsGenerating(true);
try {
// For cross-repo PRs, use the upstream's default branch SHA for the commit range.
// Using a bare branch name like "main" would resolve to the local ref, making
// "git log main..main" a no-op. The SHA points to the actual upstream commit.
const baseRef = (useDetectedUpstream && detectedUpstream?.defaultBranchSha)
? detectedUpstream.defaultBranchSha
: targetBaseBranch;
const payload: { base: string; head: string; context?: string; files?: string[] } = {
base: targetBaseBranch,
base: baseRef,
head: branch,
};
if (additionalContext) {
@@ -1142,7 +1232,7 @@ export const PullRequestSection: React.FC<{
} finally {
setIsGenerating(false);
}
}, [additionalContext, branch, directory, isGenerating, onGeneratedDescription, targetBaseBranch, t]);
}, [additionalContext, branch, detectedUpstream?.defaultBranchSha, directory, isGenerating, onGeneratedDescription, targetBaseBranch, t, useDetectedUpstream]);
const createPr = React.useCallback(async () => {
if (!github?.prCreate) {
@@ -1160,15 +1250,17 @@ export const PullRequestSection: React.FC<{
toast.error(t('gitView.pr.toast.baseBranchRequired'));
return;
}
if (trimmedBase === branch) {
if (!useDetectedUpstream && trimmedBase === branch) {
toast.error(t('gitView.pr.toast.baseMustDifferFromHead'));
return;
}
setIsCreating(true);
try {
// Let the server determine the head source from tracking info
// The server will check the branch's tracking remote and use that
const trackingRemoteName = getTrackingRemoteName(trackingBranch);
const usingDetectedUpstream = useDetectedUpstream && detectedUpstream;
const pr = await github.prCreate({
directory,
title: trimmedTitle,
@@ -1176,7 +1268,14 @@ export const PullRequestSection: React.FC<{
base: trimmedBase,
...(body.trim() ? { body } : {}),
draft,
...(selectedRemote ? { remote: selectedRemote.name } : {}),
...(usingDetectedUpstream
? { targetRepo: { owner: detectedUpstream.owner, repo: detectedUpstream.repo }, headRemote: 'origin' }
: {
...(selectedRemote ? { remote: selectedRemote.name } : {}),
...(trackingRemoteName && trackingRemoteName !== selectedRemote?.name
? { headRemote: trackingRemoteName }
: {}),
}),
});
toast.success(t('gitView.pr.toast.prCreated'));
updatePrStatus(prStatusKey, (prev) => (prev ? { ...prev, pr } : prev));
@@ -1188,7 +1287,7 @@ export const PullRequestSection: React.FC<{
} finally {
setIsCreating(false);
}
}, [body, branch, directory, draft, github, prStatusKey, refresh, scheduleActionRefresh, selectedRemote, targetBaseBranch, title, updatePrStatus, t]);
}, [body, branch, detectedUpstream, directory, draft, github, prStatusKey, refresh, scheduleActionRefresh, selectedRemote, targetBaseBranch, title, trackingBranch, updatePrStatus, useDetectedUpstream, t]);
const mergePr = React.useCallback(async (pr: GitHubPullRequest) => {
if (!github?.prMerge) {
@@ -1283,7 +1382,8 @@ export const PullRequestSection: React.FC<{
return null;
}
const repoUrl = status?.repo?.url || null;
const originRepoUrl = status?.repo?.url || null;
const repoUrl = (useDetectedUpstream && detectedUpstream?.url) ? detectedUpstream.url : originRepoUrl;
const checks = status?.checks ?? null;
const canMerge = Boolean(status?.canMerge);
const isConnected = Boolean(status?.connected);
@@ -1331,17 +1431,40 @@ export const PullRequestSection: React.FC<{
</div>
<div className="flex items-center gap-2">
{isLoading ? <RiLoader4Line className="size-4 animate-spin text-muted-foreground" /> : null}
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex size-5 items-center justify-center rounded hover:bg-interactive-hover/60 disabled:opacity-40"
disabled={isLoading}
onClick={() => void refresh({ force: true })}
aria-label={t('gitView.pr.actions.refreshAria')}
>
<RiRefreshLine className="size-3.5 text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent><p>{t('gitView.pr.actions.refresh')}</p></TooltipContent>
</Tooltip>
{checks ? (
<span className="inline-flex items-center gap-2 typography-micro text-muted-foreground">
<span className={`h-2 w-2 rounded-full ${statusColor(checks.state)}`} />
{checks.total > 0 ? `${checks.success}/${checks.total} checks` : `${checks.state} checks`}
</span>
) : null}
{hasMultipleRemotes ? (
{trackingBranch && selectedRemote && trackingBranch.split('/')[0] !== selectedRemote.name ? (
<span className="typography-micro text-muted-foreground">
{trackingBranch.split('/')[0]} {selectedRemote.name}
</span>
) : null}
{hasMultipleRemotes || detectedUpstream ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="xs" className="gap-1">
<span className="typography-micro">{selectedRemote?.name}</span>
<span className="typography-micro">
{useDetectedUpstream && detectedUpstream
? `upstream · ${detectedUpstream.owner}/${detectedUpstream.repo}`
: selectedRemote?.name ?? 'target'}
</span>
<RiArrowDownSLine className="size-3" />
</Button>
</DropdownMenuTrigger>
@@ -1349,12 +1472,15 @@ export const PullRequestSection: React.FC<{
{remotes.map((remote) => (
<DropdownMenuItem
key={remote.name}
onSelect={() => handleRemoteChange(remote)}
onSelect={() => {
setUseDetectedUpstream(false);
handleRemoteChange(remote);
}}
>
<div className="flex flex-col">
<span className="typography-ui-label text-foreground">
{remote.name}
{remote.name === selectedRemote?.name && (
{!useDetectedUpstream && remote.name === selectedRemote?.name && (
<span className="ml-2 text-primary"></span>
)}
</span>
@@ -1364,6 +1490,24 @@ export const PullRequestSection: React.FC<{
</div>
</DropdownMenuItem>
))}
{detectedUpstream ? (
<DropdownMenuItem
key="detected-upstream"
onSelect={() => setUseDetectedUpstream(true)}
>
<div className="flex flex-col">
<span className="typography-ui-label text-foreground">
upstream · {detectedUpstream.owner}/{detectedUpstream.repo}
{useDetectedUpstream && (
<span className="ml-2 text-primary"></span>
)}
</span>
<span className="typography-meta text-muted-foreground truncate">
{detectedUpstream.url}
</span>
</div>
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
@@ -1647,7 +1791,7 @@ export const PullRequestSection: React.FC<{
<div className="min-w-0">
<div className="typography-ui-label text-foreground">{t('gitView.pr.createTitle')}</div>
<div className="typography-micro text-muted-foreground truncate">
{branch} {targetBaseBranch}
{branch} <span className="opacity-60">(local)</span> {targetBaseBranch} <span className="opacity-60">({useDetectedUpstream && detectedUpstream ? 'upstream' : 'remote'})</span>
</div>
</div>
{repoUrl ? (
@@ -1822,7 +1966,7 @@ export const PullRequestSection: React.FC<{
size="sm"
className="min-w-[7.5rem] justify-center gap-2"
onClick={createPr}
disabled={isCreating || !isConnected || !targetBaseBranch.trim() || targetBaseBranch.trim() === branch}
disabled={isCreating || !isConnected || !targetBaseBranch.trim() || (!useDetectedUpstream && targetBaseBranch.trim() === branch)}
>
<span className="inline-flex size-4 items-center justify-center">
{isCreating ? <RiLoader4Line className="size-4 animate-spin" /> : <RiGitPullRequestLine className="size-4" />}