feat(source-control): show current branch without evicting Create PR (#10215)

* feat(source-control): show current branch without evicting Create PR

#9787 added the current-branch identity to the Source Control header but
did it by replacing the Create PR button's toolbar slot, so #10032 reverted
the whole thing. Create PR is the primary entry point into the
stage→commit→push→generate-PR flow, so it can't be traded away.

Restore the branch identity as its own row above the toolbar so it coexists
with the Create PR button (Option 2 layout). Detached HEAD renders in the
same identity row via DetachedHeadBadge (re-adds its tabIndex/aria-label),
replacing the separate below-toolbar badge row.

* style(source-control): match branch identity text to the 'vs main' base ref

Same font-mono / 10.5px / foreground-90 / underline treatment so the current
branch name reads visually consistent with the base ref in the context row.

* style(source-control): drop branch identity underline

Keeps the 'vs main' font/size/color match but no underline — the label isn't
clickable, so the underline read as a false affordance.

* test(source-control): harden identity-row detached + no-identity contracts

Add a stable data-testid to the identity row so the no-identity case proves no
row renders, and assert the detached badge's accessible label + focusability.
Addresses CodeRabbit review on #10215.
This commit is contained in:
Brennan Benson 2026-07-23 14:35:58 -07:00 committed by GitHub
parent 0da5380a49
commit 94d3db4a24
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 188 additions and 16 deletions

View File

@ -12,13 +12,15 @@ type DetachedHeadBadgeProps = {
label?: 'sidebar' | 'source-control'
side?: React.ComponentProps<typeof TooltipContent>['side']
className?: string
tabIndex?: number
}
export function DetachedHeadBadge({
display,
label = 'source-control',
side = 'right',
className
className,
tabIndex
}: DetachedHeadBadgeProps): React.JSX.Element {
const visibleLabel = label === 'sidebar' ? display.sidebarLabel : display.sourceControlLabel
@ -27,6 +29,8 @@ export function DetachedHeadBadge({
<TooltipTrigger asChild>
<Badge
variant="outline"
aria-label={display.tooltip}
tabIndex={tabIndex}
className={cn(
'h-[18px] shrink-0 gap-1 rounded px-1.5 text-[10px] font-medium leading-none',
'border-[color:color-mix(in_srgb,var(--git-decoration-modified)_30%,transparent)] bg-[color:color-mix(in_srgb,var(--git-decoration-modified)_8%,transparent)] text-[color:var(--git-decoration-modified)]',
@ -34,7 +38,7 @@ export function DetachedHeadBadge({
)}
>
<GitCommitHorizontal className="size-2.5" />
{visibleLabel}
<span className="min-w-0 truncate">{visibleLabel}</span>
</Badge>
</TooltipTrigger>
<TooltipContent side={side} sideOffset={8}>

View File

@ -46,7 +46,6 @@ import { isFolderRepo } from '../../../../shared/repo-kind'
import { mapSettledWithConcurrency } from '../../../../shared/map-with-concurrency'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
import { Button } from '@/components/ui/button'
import { DetachedHeadBadge } from '@/components/DetachedHeadBadge'
import {
DropdownMenu,
DropdownMenuContent,
@ -811,7 +810,6 @@ function SourceControlInner(): React.JSX.Element {
const activeRepoConnectionId = activeRepo?.connectionId ?? null
const activeRepoExecutionHostId = activeRepo?.executionHostId ?? null
const gitIdentityDisplay = activeWorktree ? getWorktreeGitIdentityDisplay(activeWorktree) : null
const detachedHeadDisplay = gitIdentityDisplay?.kind === 'detached' ? gitIdentityDisplay : null
const branchName = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : ''
const entries = useAppStore((s) =>
activeWorktreeId
@ -5461,6 +5459,7 @@ function SourceControlInner(): React.JSX.Element {
<>
<div ref={setSourceControlRoot} className="relative flex h-full flex-col overflow-hidden">
<SourceControlHeaderToolbar
gitIdentityDisplay={gitIdentityDisplay}
filterQuery={filterQuery}
filterExpanded={filterExpanded}
onFilterQueryChange={setFilterQuery}
@ -5485,12 +5484,6 @@ function SourceControlInner(): React.JSX.Element {
manualReviewUrl={manualReviewUrl}
/>
{detachedHeadDisplay && (
<div className="border-b border-border px-3 py-2">
<DetachedHeadBadge display={detachedHeadDisplay} side="bottom" />
</div>
)}
{/* Why: hidden when count is 0 — notes are created from the diff view, so an empty Notes shelf here is pure chrome. */}
{activeWorktreeId && worktreePath && diffCommentCount > 0 && (
<div className="border-b border-border">

View File

@ -0,0 +1,115 @@
import { renderToStaticMarkup } from 'react-dom/server'
import type { ReactNode } from 'react'
import { describe, expect, it, vi } from 'vitest'
import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
import type { PrimaryAction } from './source-control-primary-action'
import { SourceControlHeaderToolbar } from './source-control-header-toolbar'
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
vi.mock('./source-control-header-overflow-menu', () => ({
SourceControlHeaderOverflowMenu: () => <button type="button">More actions</button>
}))
vi.mock('./source-control-branch-context-row', () => ({
shouldShowSourceControlBranchContextRow: () => false,
SourceControlBranchContextRow: () => null
}))
const CREATE_PR_ACTION: PrimaryAction = {
kind: 'create_pr',
label: 'Create PR',
title: 'Create a pull request',
disabled: false
}
function renderToolbar(
overrides: {
gitIdentityDisplay?: WorktreeGitIdentityDisplay | null
createPrAction?: PrimaryAction | null
} = {}
): string {
const gitIdentityDisplay =
overrides.gitIdentityDisplay === undefined
? ({ kind: 'branch', branchName: 'brennanb2025/source-control-branch-name' } as const)
: overrides.gitIdentityDisplay
const createPrAction =
overrides.createPrAction === undefined ? CREATE_PR_ACTION : overrides.createPrAction
return renderToStaticMarkup(
<SourceControlHeaderToolbar
gitIdentityDisplay={gitIdentityDisplay}
filterQuery=""
filterExpanded={false}
onFilterQueryChange={vi.fn()}
onFilterExpandedChange={vi.fn()}
visibleCreatePrHeaderAction={createPrAction}
hostedReview={null}
isCreatePrIntentInFlight={false}
isCreatingPr={false}
onCreatePrHeaderClick={vi.fn()}
onOpenHostedReviewInChecks={vi.fn()}
sourceControlViewMode="list"
viewModeToggleDisabled={false}
onToggleViewMode={vi.fn()}
onChangeBaseRef={vi.fn()}
onRefreshBranchCompare={vi.fn()}
branchCompareRefreshDisabled={false}
diffCommentCount={0}
onExpandNotes={vi.fn()}
branchSummary={null}
compareBaseRef={null}
/>
)
}
describe('SourceControlHeaderToolbar branch identity', () => {
it('keeps the Create PR button while showing the branch identity above it', () => {
const markup = renderToolbar()
const branchIndex = markup.indexOf('brennanb2025/source-control-branch-name')
const createPrIndex = markup.indexOf('Create PR')
// Why: the #9787 revert regression — identity must not evict Create PR.
expect(markup).toContain('data-testid="source-control-git-identity-row"')
expect(branchIndex).toBeGreaterThan(-1)
expect(createPrIndex).toBeGreaterThan(-1)
// Identity row renders above the toolbar row that hosts Create PR.
expect(branchIndex).toBeLessThan(createPrIndex)
expect(markup).toContain('aria-label="Current branch: brennanb2025/source-control-branch-name"')
expect(markup).toContain('min-w-0 truncate')
})
it('renders detached HEAD identity alongside the Create PR button', () => {
const markup = renderToolbar({
gitIdentityDisplay: {
kind: 'detached',
shortHead: '8cec248',
sidebarLabel: 'Detached HEAD @ 8cec248',
sourceControlLabel: 'Detached HEAD · 8cec248',
tooltip: 'Detached HEAD at 8cec248. You are viewing a commit, not a branch.'
}
})
expect(markup).not.toContain('aria-label="Current branch:')
expect(markup).toContain('data-testid="source-control-git-identity-row"')
expect(markup).toContain('Detached HEAD · 8cec248')
// Detached badge stays keyboard-reachable and exposes the full tooltip as its label.
expect(markup).toContain(
'aria-label="Detached HEAD at 8cec248. You are viewing a commit, not a branch."'
)
expect(markup).toContain('tabindex="0"')
expect(markup).toContain('Create PR')
})
it('omits the identity row when there is no git identity', () => {
const markup = renderToolbar({ gitIdentityDisplay: null })
expect(markup).not.toContain('data-testid="source-control-git-identity-row"')
expect(markup).not.toContain('aria-label="Current branch:')
expect(markup).toContain('Create PR')
})
})

View File

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef } from 'react'
import { GitPullRequestArrow, Loader2, Search, X } from 'lucide-react'
import { GitBranch, GitPullRequestArrow, Loader2, Search, X } from 'lucide-react'
import type {
GitBranchCompareSummary,
GitUpstreamStatus,
@ -11,6 +11,8 @@ import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { DetachedHeadBadge } from '@/components/DetachedHeadBadge'
import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
import { HostedReviewHeaderLink, HostedReviewIcon } from './hosted-review-header-chrome'
import {
shouldShowSourceControlBranchContextRow,
@ -19,6 +21,7 @@ import {
import { SourceControlHeaderOverflowMenu } from './source-control-header-overflow-menu'
type SourceControlHeaderToolbarProps = {
gitIdentityDisplay: WorktreeGitIdentityDisplay | null
filterQuery: string
filterExpanded: boolean
onFilterQueryChange: (value: string) => void
@ -43,6 +46,56 @@ type SourceControlHeaderToolbarProps = {
manualReviewUrl?: string | null
}
// Why: its own row above the toolbar so branch identity coexists with the Create PR
// button instead of competing for the single toolbar slot (#9787 restore).
function SourceControlGitIdentityRow({
display
}: {
display: WorktreeGitIdentityDisplay
}): React.JSX.Element {
if (display.kind === 'detached') {
return (
<div data-testid="source-control-git-identity-row" className="mb-1 flex min-w-0 items-center">
<DetachedHeadBadge
display={display}
side="bottom"
className="min-w-0 max-w-full shrink"
tabIndex={0}
/>
</div>
)
}
const branchName = display.branchName
const label = translate(
'auto.components.right.sidebar.SourceControl.a4e93c21d7',
'Current branch: {{value0}}',
{ value0: branchName }
)
return (
<div data-testid="source-control-git-identity-row" className="mb-1 flex min-w-0 items-center">
<Tooltip>
<TooltipTrigger asChild>
<span
className="flex min-w-0 items-center gap-1 rounded-sm font-mono text-[10.5px] font-medium leading-none text-foreground/90 outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label={label}
tabIndex={0}
>
<GitBranch className="size-3 shrink-0 text-muted-foreground" aria-hidden="true" />
{/* Why: match the 'vs main' base-ref typography (font/size/color) but no
underline this label isn't clickable, so the underline would mislead. */}
<span className="min-w-0 truncate">{branchName}</span>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} className="max-w-72 break-all font-mono">
{branchName}
</TooltipContent>
</Tooltip>
</div>
)
}
function HostedReviewToolbarLink({
review,
onOpenHostedReviewInChecks,
@ -124,6 +177,7 @@ function renderOverflowMenu(
}
export function SourceControlHeaderToolbar({
gitIdentityDisplay,
filterQuery,
filterExpanded,
onFilterQueryChange,
@ -190,6 +244,7 @@ export function SourceControlHeaderToolbar({
return (
<div className="border-b border-border px-3 pt-1.5 pb-1">
{gitIdentityDisplay ? <SourceControlGitIdentityRow display={gitIdentityDisplay} /> : null}
<div
className={cn('flex min-w-0 items-center gap-1', filterExpanded && 'w-full gap-1.5')}
data-filter-expanded={filterExpanded ? 'true' : 'false'}

View File

@ -9908,7 +9908,8 @@
"834cb3f23d": "Fix with AI",
"783a808870": "Close"
},
"97e7124eac": "Could not refresh Source Control. Try again."
"97e7124eac": "Could not refresh Source Control. Try again.",
"a4e93c21d7": "Current branch: {{value0}}"
},
"SourceControlAgentActionDialog": {
"8e856842d1": "Could not start the selected agent.",

View File

@ -9885,7 +9885,8 @@
"834cb3f23d": "Corregir con AI",
"783a808870": "Cerrar"
},
"97e7124eac": "No se pudo actualizar Source Control. Vuelve a intentarlo."
"97e7124eac": "No se pudo actualizar Source Control. Vuelve a intentarlo.",
"a4e93c21d7": "Rama actual: {{value0}}"
},
"SourceControlAgentActionDialog": {
"8e856842d1": "No se pudo iniciar el agente seleccionado.",

View File

@ -9885,7 +9885,8 @@
"834cb3f23d": "AIで修正",
"783a808870": "閉じる"
},
"97e7124eac": "Source Control を更新できませんでした。もう一度お試しください。"
"97e7124eac": "Source Control を更新できませんでした。もう一度お試しください。",
"a4e93c21d7": "現在のブランチ: {{value0}}"
},
"SourceControlAgentActionDialog": {
"8e856842d1": "選択した agent を開始できませんでした。",

View File

@ -9885,7 +9885,8 @@
"834cb3f23d": "AI로 수정",
"783a808870": "닫기"
},
"97e7124eac": "Source Control을 새로 고칠 수 없습니다. 다시 시도하세요."
"97e7124eac": "Source Control을 새로 고칠 수 없습니다. 다시 시도하세요.",
"a4e93c21d7": "현재 브랜치: {{value0}}"
},
"SourceControlAgentActionDialog": {
"8e856842d1": "선택한 agent를 시작할 수 없습니다.",

View File

@ -9885,7 +9885,8 @@
"834cb3f23d": "使用 AI 修复",
"783a808870": "关闭"
},
"97e7124eac": "无法刷新 Source Control。请重试。"
"97e7124eac": "无法刷新 Source Control。请重试。",
"a4e93c21d7": "当前分支:{{value0}}"
},
"SourceControlAgentActionDialog": {
"8e856842d1": "无法启动选定的智能体。",