diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx
index 59a44672999..4c696178a18 100644
--- a/src/renderer/src/components/right-sidebar/SourceControl.tsx
+++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx
@@ -278,6 +278,7 @@ import {
resolveHostedReviewActionUpstreamStatus,
resolveHostedReviewStateForActions
} from './source-control-hosted-review-push-target'
+import { buildSourceControlManualReviewUrl } from './source-control-manual-review-url'
export { HostedReviewHeaderLink } from './hosted-review-header-chrome'
import {
createRunningCommitMessageGenerationRecord,
@@ -1467,6 +1468,41 @@ function SourceControlInner(): React.JSX.Element {
const linkedBitbucketPR = activeWorktree?.linkedBitbucketPR ?? null
const linkedAzureDevOpsPR = activeWorktree?.linkedAzureDevOpsPR ?? null
const linkedGiteaPR = activeWorktree?.linkedGiteaPR ?? null
+ const manualReviewProvider: HostedReviewProvider | null =
+ hostedReview?.provider ??
+ hostedReviewCreation?.provider ??
+ (linkedGitLabMR !== null
+ ? 'gitlab'
+ : linkedBitbucketPR !== null
+ ? 'bitbucket'
+ : linkedAzureDevOpsPR !== null
+ ? 'azure-devops'
+ : linkedGiteaPR !== null
+ ? 'gitea'
+ : linkedGitHubPR !== null || fallbackGitHubPRNumber !== null
+ ? 'github'
+ : null)
+ const manualReviewUrl = useMemo(
+ () =>
+ buildSourceControlManualReviewUrl({
+ baseRef: compareBaseRef,
+ branchName,
+ repoRemoteName: activeRepo?.gitRemoteIdentity?.remoteName ?? null,
+ repoRemoteUrl: activeRepo?.gitRemoteIdentity?.remoteUrl ?? null,
+ provider: manualReviewProvider,
+ pushTarget: activeWorktree?.pushTarget ?? null,
+ upstreamName: remoteStatus?.upstreamName ?? null
+ }),
+ [
+ activeRepo?.gitRemoteIdentity?.remoteName,
+ activeRepo?.gitRemoteIdentity?.remoteUrl,
+ activeWorktree?.pushTarget,
+ branchName,
+ compareBaseRef,
+ manualReviewProvider,
+ remoteStatus?.upstreamName
+ ]
+ )
const shouldResolveHostedReviewCreation =
isBranchVisible &&
Boolean(activeRepo) &&
@@ -5415,6 +5451,7 @@ function SourceControlInner(): React.JSX.Element {
branchSummary={branchSummary}
compareBaseRef={compareBaseRef}
upstreamStatus={remoteStatus}
+ manualReviewUrl={manualReviewUrl}
/>
{detachedHeadDisplay && (
diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx
index c9eb2fad92d..8deb9aeae0f 100644
--- a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx
+++ b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx
@@ -37,4 +37,18 @@ describe('SourceControlBranchContextRow', () => {
expect(markup).toContain('min-w-0 flex-1')
expect(markup).not.toContain('max-w-[9rem]')
})
+
+ it('renders a compact external review link when a manual URL is available', () => {
+ const markup = renderToStaticMarkup(
+
+ )
+
+ expect(markup).toContain('aria-label="Open review page in browser"')
+ })
})
diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.tsx b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.tsx
index 27d1183d5ac..8cd5f078e04 100644
--- a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.tsx
+++ b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.tsx
@@ -1,5 +1,5 @@
import React from 'react'
-import { Loader2, RefreshCw } from 'lucide-react'
+import { ExternalLink, Loader2, RefreshCw } from 'lucide-react'
import type { GitBranchCompareSummary, GitUpstreamStatus } from '../../../../shared/types'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
@@ -60,16 +60,40 @@ function ContextStat({
)
}
+function ManualReviewLinkButton({
+ url
+}: {
+ url: string | null | undefined
+}): React.JSX.Element | null {
+ if (!url) {
+ return null
+ }
+ return (
+ {
+ void window.api.shell.openUrl(url)
+ }}
+ />
+ )
+}
+
export function SourceControlBranchContextRow({
summary,
compareBaseRef,
upstreamStatus,
+ manualReviewUrl,
onChangeBaseRef,
onRetry
}: {
summary: GitBranchCompareSummary | null
compareBaseRef: string | null
upstreamStatus?: GitUpstreamStatus
+ manualReviewUrl?: string | null
onChangeBaseRef: () => void
onRetry: () => void
}): React.JSX.Element | null {
@@ -97,6 +121,7 @@ export function SourceControlBranchContextRow({
title={changeBaseTitle}
/>
+
)
}
@@ -118,6 +143,7 @@ export function SourceControlBranchContextRow({
'Branch compare unavailable'
)}
+
) : null}
+
)
}
diff --git a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx b/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx
index f66de4f47c3..3cac51e1e9f 100644
--- a/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx
+++ b/src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx
@@ -40,6 +40,7 @@ type SourceControlHeaderToolbarProps = {
branchSummary: GitBranchCompareSummary | null
compareBaseRef: string | null
upstreamStatus?: GitUpstreamStatus
+ manualReviewUrl?: string | null
}
function HostedReviewToolbarLink({
@@ -143,7 +144,8 @@ export function SourceControlHeaderToolbar({
onExpandNotes,
branchSummary,
compareBaseRef,
- upstreamStatus
+ upstreamStatus,
+ manualReviewUrl
}: SourceControlHeaderToolbarProps): React.JSX.Element {
const filterInputRef = useRef(null)
const normalizedFilter = filterQuery.trim()
@@ -288,6 +290,7 @@ export function SourceControlHeaderToolbar({
summary={branchSummary}
compareBaseRef={compareBaseRef}
upstreamStatus={upstreamStatus}
+ manualReviewUrl={manualReviewUrl}
onChangeBaseRef={onChangeBaseRef}
onRetry={onRefreshBranchCompare}
/>
diff --git a/src/renderer/src/components/right-sidebar/source-control-manual-review-url.test.ts b/src/renderer/src/components/right-sidebar/source-control-manual-review-url.test.ts
new file mode 100644
index 00000000000..532f6867047
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/source-control-manual-review-url.test.ts
@@ -0,0 +1,173 @@
+import { describe, expect, it } from 'vitest'
+import { buildSourceControlManualReviewUrl } from './source-control-manual-review-url'
+
+describe('buildSourceControlManualReviewUrl', () => {
+ it('builds a GitHub compare URL for the current remote branch', () => {
+ expect(
+ buildSourceControlManualReviewUrl({
+ baseRef: 'refs/remotes/origin/main',
+ branchName: 'native-chat-does-not-auto-open',
+ repoRemoteName: 'origin',
+ repoRemoteUrl: 'git@github.com:stablyai/orca.git',
+ upstreamName: 'origin/native-chat-does-not-auto-open'
+ })
+ ).toBe(
+ 'https://github.com/stablyai/orca/compare/main...native-chat-does-not-auto-open?expand=1'
+ )
+ })
+
+ it('qualifies GitHub fork heads when the push target remote differs from the base repo', () => {
+ expect(
+ buildSourceControlManualReviewUrl({
+ baseRef: 'refs/remotes/upstream/main',
+ branchName: 'feature/fork-head',
+ repoRemoteName: 'upstream',
+ repoRemoteUrl: 'https://github.com/stablyai/orca.git',
+ pushTarget: {
+ remoteName: 'fork',
+ branchName: 'feature/fork-head',
+ remoteUrl: 'git@github.com:contributor/orca.git'
+ }
+ })
+ ).toBe('https://github.com/stablyai/orca/compare/main...contributor:feature/fork-head?expand=1')
+ })
+
+ it('keeps slashes literal in a GitHub compare URL for a slash-containing branch name', () => {
+ expect(
+ buildSourceControlManualReviewUrl({
+ baseRef: 'refs/remotes/origin/main',
+ branchName: 'slashdevcorpse/identifying-pwsh.exe-error',
+ repoRemoteName: 'origin',
+ repoRemoteUrl: 'git@github.com:stablyai/orca.git',
+ upstreamName: 'origin/slashdevcorpse/identifying-pwsh.exe-error'
+ })
+ ).toBe(
+ 'https://github.com/stablyai/orca/compare/main...slashdevcorpse/identifying-pwsh.exe-error?expand=1'
+ )
+ })
+
+ it('builds a GitLab merge request URL for a self-hosted GitLab remote', () => {
+ expect(
+ buildSourceControlManualReviewUrl({
+ baseRef: 'refs/remotes/origin/release/next',
+ branchName: 'feature/gitlab',
+ repoRemoteName: 'origin',
+ repoRemoteUrl: 'git@gitlab.company.test:group/sub/orca.git',
+ provider: 'gitlab',
+ upstreamName: 'origin/feature/gitlab'
+ })
+ ).toBe(
+ 'https://gitlab.company.test/group/sub/orca/-/merge_requests/new?merge_request%5Bsource_branch%5D=feature%2Fgitlab&merge_request%5Btarget_branch%5D=release%2Fnext'
+ )
+ })
+
+ it('builds a Bitbucket manual pull request URL', () => {
+ expect(
+ buildSourceControlManualReviewUrl({
+ baseRef: 'origin/main',
+ branchName: 'feature/bitbucket',
+ repoRemoteName: 'origin',
+ repoRemoteUrl: 'git@bitbucket.org:team/project.git',
+ upstreamName: 'origin/feature/bitbucket'
+ })
+ ).toBe(
+ 'https://bitbucket.org/team/project/pull-requests/new?source=feature%2Fbitbucket&dest=main'
+ )
+ })
+
+ it('builds an Azure DevOps pull request creation URL', () => {
+ expect(
+ buildSourceControlManualReviewUrl({
+ baseRef: 'refs/remotes/origin/main',
+ branchName: 'feature/azure',
+ repoRemoteName: 'origin',
+ repoRemoteUrl: 'git@ssh.dev.azure.com:v3/acme/widgets/widget-app.git',
+ upstreamName: 'origin/feature/azure'
+ })
+ ).toBe(
+ 'https://dev.azure.com/acme/widgets/_git/widget-app/pullrequestcreate?sourceRef=refs%2Fheads%2Ffeature%2Fazure&targetRef=refs%2Fheads%2Fmain'
+ )
+ })
+
+ it('builds a Gitea compare URL when the provider is known', () => {
+ expect(
+ buildSourceControlManualReviewUrl({
+ baseRef: 'refs/remotes/origin/main',
+ branchName: 'feature/gitea',
+ repoRemoteName: 'origin',
+ repoRemoteUrl: 'ssh://git@gitea.company.test/team/orca.git',
+ provider: 'gitea',
+ upstreamName: 'origin/feature/gitea'
+ })
+ ).toBe('https://gitea.company.test/team/orca/compare/main...feature/gitea')
+ })
+
+ it('suppresses the link when the branch tracks a fork remote with no resolvable push URL', () => {
+ // Repro: branch pushed only to a fork (tracked via plain git, so pushTarget
+ // is empty). A base-repo compare/main...quick-commands would 404 on origin.
+ expect(
+ buildSourceControlManualReviewUrl({
+ baseRef: 'refs/remotes/origin/main',
+ branchName: 'quick-commands',
+ repoRemoteName: 'origin',
+ repoRemoteUrl: 'https://github.com/stablyai/orca.git',
+ upstreamName: 'pr-devajmeireles-orca/quick-commands'
+ })
+ ).toBeNull()
+ })
+
+ it('uses the pushed upstream branch name when the local branch tracks a differently named branch on the base remote', () => {
+ expect(
+ buildSourceControlManualReviewUrl({
+ baseRef: 'refs/remotes/origin/main',
+ branchName: 'local-wip-name',
+ repoRemoteName: 'origin',
+ repoRemoteUrl: 'git@github.com:stablyai/orca.git',
+ upstreamName: 'origin/feature/pushed-name'
+ })
+ ).toBe('https://github.com/stablyai/orca/compare/main...feature/pushed-name?expand=1')
+ })
+
+ it('still qualifies the fork head when Orca resolved the fork push URL', () => {
+ expect(
+ buildSourceControlManualReviewUrl({
+ baseRef: 'refs/remotes/origin/main',
+ branchName: 'quick-commands',
+ repoRemoteName: 'origin',
+ repoRemoteUrl: 'https://github.com/stablyai/orca.git',
+ upstreamName: 'pr-devajmeireles-orca/quick-commands',
+ pushTarget: {
+ remoteName: 'pr-devajmeireles-orca',
+ branchName: 'quick-commands',
+ remoteUrl: 'git@github.com:devajmeireles/orca.git'
+ }
+ })
+ ).toBe('https://github.com/stablyai/orca/compare/main...devajmeireles:quick-commands?expand=1')
+ })
+
+ it('does not guess a provider for unknown hosts without a provider hint', () => {
+ expect(
+ buildSourceControlManualReviewUrl({
+ baseRef: 'refs/remotes/origin/main',
+ branchName: 'feature/unknown',
+ repoRemoteName: 'origin',
+ repoRemoteUrl: 'git@git.company.test:team/orca.git',
+ upstreamName: 'origin/feature/unknown'
+ })
+ ).toBeNull()
+ })
+
+ it('suppresses the link when the branch was never pushed (no upstream, no push target)', () => {
+ // Repro: local-only branch. main... lands on GitHub's
+ // "There isn't anything to compare" page.
+ expect(
+ buildSourceControlManualReviewUrl({
+ baseRef: 'refs/remotes/origin/main',
+ branchName: 'codex-runtime-home-refactor-design',
+ repoRemoteName: 'origin',
+ repoRemoteUrl: 'git@github.com:stablyai/orca.git',
+ upstreamName: null
+ })
+ ).toBeNull()
+ })
+})
diff --git a/src/renderer/src/components/right-sidebar/source-control-manual-review-url.ts b/src/renderer/src/components/right-sidebar/source-control-manual-review-url.ts
new file mode 100644
index 00000000000..6b261a8313a
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/source-control-manual-review-url.ts
@@ -0,0 +1,338 @@
+import type { HostedReviewProvider } from '../../../../shared/hosted-review'
+import type { GitPushTarget } from '../../../../shared/types'
+
+type ManualReviewProvider = Exclude
+
+type RemoteRepoRef = {
+ provider: ManualReviewProvider | null
+ webBaseUrl: string
+ path: string
+}
+
+type ManualReviewUrlInput = {
+ baseRef: string | null | undefined
+ branchName: string | null | undefined
+ repoRemoteName?: string | null
+ repoRemoteUrl?: string | null
+ provider?: HostedReviewProvider | null
+ pushTarget?: GitPushTarget | null
+ /** Tracking upstream in `/` form (git `branch.upstream`). */
+ upstreamName?: string | null
+}
+
+type ParsedUpstream = { remoteName: string; branchName: string }
+
+function parseUpstream(name: string | null | undefined): ParsedUpstream | null {
+ const trimmed = name?.trim()
+ if (!trimmed) {
+ return null
+ }
+ const slash = trimmed.indexOf('/')
+ if (slash <= 0 || slash === trimmed.length - 1) {
+ return null
+ }
+ return { remoteName: trimmed.slice(0, slash), branchName: trimmed.slice(slash + 1) }
+}
+
+function decodeSegment(value: string): string {
+ try {
+ return decodeURIComponent(value)
+ } catch {
+ return value
+ }
+}
+
+function encodePath(path: string): string {
+ return path.split('/').map(encodeURIComponent).join('/')
+}
+
+function cleanPath(path: string): string | null {
+ const parts = path
+ .replace(/^\/+/, '')
+ .replace(/\/+$/, '')
+ .replace(/\.git$/i, '')
+ .split('/')
+ .map((part) => part.trim())
+ .filter(Boolean)
+ .map(decodeSegment)
+ return parts.length >= 2 ? parts.join('/') : null
+}
+
+function providerForHost(host: string): ManualReviewProvider | null {
+ const normalized = host.toLowerCase()
+ if (normalized === 'github.com' || normalized === 'ssh.github.com') {
+ return 'github'
+ }
+ if (normalized === 'gitlab.com') {
+ return 'gitlab'
+ }
+ if (normalized === 'bitbucket.org') {
+ return 'bitbucket'
+ }
+ if (
+ normalized === 'dev.azure.com' ||
+ normalized === 'ssh.dev.azure.com' ||
+ normalized.endsWith('.visualstudio.com')
+ ) {
+ return 'azure-devops'
+ }
+ return null
+}
+
+function normalizeProvider(
+ provider: HostedReviewProvider | null | undefined
+): ManualReviewProvider | null {
+ return provider && provider !== 'unsupported' ? provider : null
+}
+
+function buildWebOrigin(protocol: string, host: string, hostname: string): string {
+ return protocol === 'http:' || protocol === 'https:'
+ ? `${protocol}//${host}`
+ : `https://${hostname}`
+}
+
+function parseAzureDevOpsRemote(trimmed: string): RemoteRepoRef | null {
+ const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/)
+ if (scpLike && scpLike[1].toLowerCase() === 'ssh.dev.azure.com') {
+ const parts = cleanPath(scpLike[2])?.split('/') ?? []
+ if (parts.length >= 4 && parts[0].toLowerCase() === 'v3') {
+ const [, organization, project, repository] = parts
+ return {
+ provider: 'azure-devops',
+ path: `${organization}/${project}/_git/${repository}`,
+ webBaseUrl: `https://dev.azure.com/${encodePath(organization)}/${encodeURIComponent(project)}/_git/${encodeURIComponent(repository)}`
+ }
+ }
+ }
+
+ try {
+ const url = new URL(trimmed)
+ const protocol = url.protocol.toLowerCase()
+ if (!['http:', 'https:', 'ssh:', 'git+ssh:'].includes(protocol)) {
+ return null
+ }
+ const host = url.hostname.toLowerCase()
+ const parts = cleanPath(url.pathname)?.split('/') ?? []
+ if (host === 'ssh.dev.azure.com' && parts.length >= 4 && parts[0].toLowerCase() === 'v3') {
+ const [, organization, project, repository] = parts
+ return {
+ provider: 'azure-devops',
+ path: `${organization}/${project}/_git/${repository}`,
+ webBaseUrl: `https://dev.azure.com/${encodePath(organization)}/${encodeURIComponent(project)}/_git/${encodeURIComponent(repository)}`
+ }
+ }
+
+ const gitIndex = parts.findIndex((part) => part.toLowerCase() === '_git')
+ if (gitIndex < 1 || gitIndex + 1 >= parts.length) {
+ return null
+ }
+ const project = parts[gitIndex - 1]
+ const repository = parts[gitIndex + 1]
+ const prefix = parts.slice(0, gitIndex - 1)
+ const organization =
+ host === 'dev.azure.com' ? prefix[0] : host.endsWith('.visualstudio.com') ? null : undefined
+ const webPath =
+ organization === undefined
+ ? [...prefix, project, '_git', repository]
+ : organization
+ ? [organization, project, '_git', repository]
+ : [...prefix, project, '_git', repository]
+ return {
+ provider: 'azure-devops',
+ path: webPath.join('/'),
+ webBaseUrl: `${buildWebOrigin(protocol, url.host, url.hostname).replace(/\/+$/, '')}/${encodePath(webPath.join('/'))}`
+ }
+ } catch {
+ return null
+ }
+}
+
+function parseRemoteRepo(
+ remoteUrl: string,
+ providerHint?: HostedReviewProvider | null
+): RemoteRepoRef | null {
+ const trimmed = remoteUrl.trim().replace(/^git\+/, '')
+ if (!trimmed || /^[A-Za-z]:[\\/]/.test(trimmed) || trimmed.startsWith('/')) {
+ return null
+ }
+
+ const azure = parseAzureDevOpsRemote(trimmed)
+ if (azure && (providerHint == null || providerHint === 'azure-devops')) {
+ return azure
+ }
+
+ const scpLike = !trimmed.includes('://')
+ ? trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/)
+ : null
+ if (scpLike) {
+ const host = scpLike[1].toLowerCase()
+ const path = cleanPath(scpLike[2])
+ if (!path) {
+ return null
+ }
+ const hintedProvider = normalizeProvider(providerHint)
+ return {
+ provider: hintedProvider ?? providerForHost(host) ?? null,
+ path,
+ webBaseUrl: `https://${host}/${encodePath(path)}`
+ }
+ }
+
+ try {
+ const url = new URL(trimmed)
+ const protocol = url.protocol.toLowerCase()
+ if (!['git:', 'http:', 'https:', 'ssh:'].includes(protocol)) {
+ return null
+ }
+ const path = cleanPath(url.pathname)
+ if (!path) {
+ return null
+ }
+ const host = url.hostname.toLowerCase()
+ const hintedProvider = normalizeProvider(providerHint)
+ const inferredProvider = providerForHost(host)
+ const provider = hintedProvider ?? inferredProvider
+ const webOrigin =
+ host === 'ssh.github.com'
+ ? 'https://github.com'
+ : buildWebOrigin(protocol, url.host, url.hostname).replace(/\/+$/, '')
+ return {
+ provider,
+ path,
+ webBaseUrl: `${webOrigin}/${encodePath(path)}`
+ }
+ } catch {
+ return null
+ }
+}
+
+function branchFromRef(ref: string | null | undefined, remoteName?: string | null): string | null {
+ const trimmed = ref?.trim()
+ if (!trimmed) {
+ return null
+ }
+ const prefixes = remoteName
+ ? [`refs/remotes/${remoteName}/`, `remotes/${remoteName}/`, `${remoteName}/`]
+ : ['refs/heads/']
+ for (const prefix of prefixes) {
+ if (trimmed.startsWith(prefix)) {
+ return trimmed.slice(prefix.length) || null
+ }
+ }
+ if (trimmed.startsWith('refs/remotes/')) {
+ const remoteAndBranch = trimmed.slice('refs/remotes/'.length)
+ const slashIndex = remoteAndBranch.indexOf('/')
+ return slashIndex > 0 ? remoteAndBranch.slice(slashIndex + 1) : null
+ }
+ if (trimmed.startsWith('remotes/')) {
+ const remoteAndBranch = trimmed.slice('remotes/'.length)
+ const slashIndex = remoteAndBranch.indexOf('/')
+ return slashIndex > 0 ? remoteAndBranch.slice(slashIndex + 1) : null
+ }
+ if (trimmed.startsWith('refs/heads/')) {
+ return trimmed.slice('refs/heads/'.length) || null
+ }
+ return trimmed
+}
+
+function githubHeadRef(base: RemoteRepoRef, head: RemoteRepoRef, branch: string): string {
+ if (base.path.toLowerCase() === head.path.toLowerCase()) {
+ return branch
+ }
+ const owner = head.path.split('/')[0]
+ return owner ? `${owner}:${branch}` : branch
+}
+
+function appendQuery(url: string, values: Record): string {
+ const search = new URLSearchParams(values)
+ return `${url}?${search.toString()}`
+}
+
+// GitHub/Gitea compare refs keep '/' (slashed branch names like feature/foo) and
+// ':' (the owner:branch fork qualifier) literal; percent-encoding those separators
+// makes GitHub fail to resolve the branch. Only the segments between them are encoded.
+function encodeCompareRef(ref: string): string {
+ return ref
+ .split(':')
+ .map((segment) => segment.split('/').map(encodeURIComponent).join('/'))
+ .join(':')
+}
+
+export function buildSourceControlManualReviewUrl(input: ManualReviewUrlInput): string | null {
+ const baseBranch = branchFromRef(input.baseRef, input.repoRemoteName)
+ const localBranch = input.branchName?.trim()
+ if (!baseBranch || !localBranch || localBranch === 'HEAD') {
+ return null
+ }
+
+ const baseRemoteUrl = input.repoRemoteUrl?.trim() || input.pushTarget?.remoteUrl?.trim()
+ const headRemoteUrl = input.pushTarget?.remoteUrl?.trim() || baseRemoteUrl
+ if (!baseRemoteUrl || !headRemoteUrl) {
+ return null
+ }
+
+ // Why: the tracking upstream is the source of truth for where the branch
+ // actually lives on the remote. When it tracks a remote we can't resolve to a
+ // URL (a fork tracked via plain git, so pushTarget is empty), a base-repo
+ // compare link would 404 — suppress instead of pointing at a missing branch.
+ const upstream = parseUpstream(input.upstreamName)
+ const baseRemoteName = input.repoRemoteName?.trim() || null
+ const hasResolvableForkUrl = Boolean(input.pushTarget?.remoteUrl?.trim())
+ if (
+ upstream &&
+ baseRemoteName &&
+ upstream.remoteName !== baseRemoteName &&
+ !hasResolvableForkUrl
+ ) {
+ return null
+ }
+
+ const baseRepo = parseRemoteRepo(baseRemoteUrl, input.provider)
+ const headRepo = parseRemoteRepo(headRemoteUrl, input.provider)
+ if (!baseRepo || !headRepo) {
+ return null
+ }
+
+ // Why: without a pushTarget or tracking upstream there's no evidence the branch
+ // exists on any remote, so a compare/PR-create link would land on GitHub's
+ // "There isn't anything to compare" page. No link beats a broken one.
+ const pushedBranch = input.pushTarget?.branchName?.trim()
+ if (!pushedBranch && !upstream) {
+ return null
+ }
+
+ const provider = normalizeProvider(input.provider) ?? baseRepo.provider ?? headRepo.provider
+ // Prefer the pushed branch name (pushTarget, else the tracking upstream on the
+ // base remote) so the link matches what exists remotely, not the local name.
+ const headBranch =
+ pushedBranch ||
+ (upstream && (!baseRemoteName || upstream.remoteName === baseRemoteName)
+ ? upstream.branchName
+ : localBranch)
+
+ switch (provider) {
+ case 'github':
+ return `${baseRepo.webBaseUrl}/compare/${encodeCompareRef(baseBranch)}...${encodeCompareRef(
+ githubHeadRef(baseRepo, headRepo, headBranch)
+ )}?expand=1`
+ case 'gitlab':
+ return appendQuery(`${baseRepo.webBaseUrl}/-/merge_requests/new`, {
+ 'merge_request[source_branch]': headBranch,
+ 'merge_request[target_branch]': baseBranch
+ })
+ case 'bitbucket':
+ return appendQuery(`${baseRepo.webBaseUrl}/pull-requests/new`, {
+ source: headBranch,
+ dest: baseBranch
+ })
+ case 'azure-devops':
+ return appendQuery(`${baseRepo.webBaseUrl}/pullrequestcreate`, {
+ sourceRef: `refs/heads/${headBranch}`,
+ targetRef: `refs/heads/${baseBranch}`
+ })
+ case 'gitea':
+ return `${baseRepo.webBaseUrl}/compare/${encodeCompareRef(baseBranch)}...${encodeCompareRef(headBranch)}`
+ default:
+ return null
+ }
+}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 0695879260a..4ce218d3ae4 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -6401,8 +6401,6 @@
"a62b4cb39a": "Save Changes",
"29af933cd5": "New SSH Target",
"f2331ce599": "Edit SSH Target",
- "7c13f58c91": "Until reset",
- "55c56cf2c7": "Timeout after disconnect (seconds)",
"4a342f44c1": "Advanced Connection",
"e9609ddca6": "Proxy, jump host, and connection reuse",
"8c922dffba": "Reuse SSH connection for faster setup",
@@ -9165,7 +9163,8 @@
"b715ef615b": "{{value0}} commits ahead of {{value1}}",
"c1a8f3e204": "1 commit behind {{value0}}",
"d2b9g4f315": "{{value0}} commits behind {{value1}}",
- "e9c2a5d416": "Even with {{value0}}"
+ "e9c2a5d416": "Even with {{value0}}",
+ "4b4a7de138": "Open review page in browser"
},
"SourceControlAgentActionDialog": {
"8e856842d1": "Could not start the selected agent.",
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 282c041e488..9e71eb0da71 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -9163,7 +9163,8 @@
"b715ef615b": "{{value0}} commits adelante de {{value1}}",
"c1a8f3e204": "1 commit detrás de {{value0}}",
"d2b9g4f315": "{{value0}} commits detrás de {{value1}}",
- "e9c2a5d416": "A la par con {{value0}}"
+ "e9c2a5d416": "A la par con {{value0}}",
+ "4b4a7de138": "Open review page in browser"
},
"SourceControlAgentActionDialog": {
"8e856842d1": "No se pudo iniciar el agente seleccionado.",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index db058eddd76..c994f32cac6 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -9163,7 +9163,8 @@
"b715ef615b": "{{value1}}より{{value0}}commits 進んでいます",
"c1a8f3e204": "{{value0}}より1commit 遅れています",
"d2b9g4f315": "{{value1}}より{{value0}}commits 遅れています",
- "e9c2a5d416": "{{value0}}と同じ状態"
+ "e9c2a5d416": "{{value0}}と同じ状態",
+ "4b4a7de138": "Open review page in browser"
},
"SourceControlAgentActionDialog": {
"8e856842d1": "選択した agent を開始できませんでした。",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 3c120d72f13..3acc372f8bc 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -9163,7 +9163,8 @@
"b715ef615b": "{{value1}}보다 {{value0}} commits 앞서 있음",
"c1a8f3e204": "{{value0}}보다 1 commit 뒤처짐",
"d2b9g4f315": "{{value1}}보다 {{value0}} commits 뒤처짐",
- "e9c2a5d416": "{{value0}}와 동일한 상태"
+ "e9c2a5d416": "{{value0}}와 동일한 상태",
+ "4b4a7de138": "Open review page in browser"
},
"SourceControlAgentActionDialog": {
"8e856842d1": "선택한 agent를 시작할 수 없습니다.",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 7510e6718e5..e01451e9763 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -9163,7 +9163,8 @@
"b715ef615b": "领先 {{value1}} {{value0}} 个提交",
"c1a8f3e204": "落后 {{value0}} 1 个提交",
"d2b9g4f315": "落后 {{value1}} {{value0}} 个提交",
- "e9c2a5d416": "与 {{value0}} 同步"
+ "e9c2a5d416": "与 {{value0}} 同步",
+ "4b4a7de138": "Open review page in browser"
},
"SourceControlAgentActionDialog": {
"8e856842d1": "无法启动选定的智能体。",