Clarify upstream divergence stats for rebased branches (#16358)

* Clarify upstream divergence stats for rebased branches

When a branch is rebased, it still tracks the pre-rebase upstream
while comparing against the new base. Move upstream arrows to the
head line to prevent them being confused with compare-base counts.

* Show upstream divergence stats independent of compare base

Measure HEAD against upstream regardless of compare-base state,
so divergence indicators stay visible even when comparison is
missing, loading, or failed. Also use cross-platform temp paths
in tests.

* Show commit counts against compare base, not upstream

Upstream divergence (↑/↓ against tracking branch) was confusing for
rebased branches — the counts appeared beside the base ref but measured
against the upstream branch. Show only the compare base count instead,
on the line that names it.

* Report branch divergence in both directions

Rebased branches are typically ahead AND behind their base; a single count
hides this case. Use symmetric range with --left-right --count to capture
both directions efficiently, then expose commitsBehind in the UI alongside
commitsAhead.

* Use semantic names for i18n keys and template variables

Rename hash-based translation keys to descriptive identifiers and replace generic value0/value1 placeholders with semantic variable names like `count` and `ref`. Improves code maintainability and makes translation strings self-documenting.
This commit is contained in:
Jinjing
2026-08-25 22:19:04 -07:00
committed by GitHub
parent 07b82340f3
commit 933345d347
22 changed files with 424 additions and 302 deletions
@@ -8,7 +8,7 @@ import type { GitRuntimeOptions } from '../git-runtime-options'
import { resolveWorktreeBaseCommitOid } from '../worktree-base-ref-probe'
import { loadBranchChanges } from './branch-change-entries'
import {
countAheadCommits,
countCompareDivergence,
resolveCompareRef,
resolveMergeBase,
resolveRefOid
@@ -65,6 +65,7 @@ export async function getBranchCompare(
// Why: an unborn branch (new remote worktree) has no changes yet; a compare error would look broken.
summary.changedFiles = 0
summary.commitsAhead = 0
summary.commitsBehind = 0
summary.status = 'ready'
return { summary, entries: [] }
}
@@ -94,12 +95,13 @@ export async function getBranchCompare(
}
try {
const [entries, commitsAhead] = await Promise.all([
const [entries, divergence] = await Promise.all([
loadBranchChanges(worktreePath, mergeBase, headOid, options),
countAheadCommits(worktreePath, baseOid, headOid, options)
countCompareDivergence(worktreePath, baseOid, headOid, options)
])
summary.changedFiles = entries.length
summary.commitsAhead = commitsAhead
summary.commitsAhead = divergence.ahead
summary.commitsBehind = divergence.behind
summary.status = 'ready'
return { summary, entries }
} catch (error) {
@@ -40,14 +40,21 @@ export async function resolveMergeBase(
return stdout.trim()
}
export async function countAheadCommits(
// Why: `--left-right --count` on the symmetric range answers both directions in one
// rev-list walk; a rebased branch is usually ahead AND behind its base.
export async function countCompareDivergence(
worktreePath: string,
baseOid: string,
headOid: string,
options: GitRuntimeOptions = {}
): Promise<number> {
const { stdout } = await gitExecFileAsync(['rev-list', '--count', `${baseOid}..${headOid}`], {
...gitOptionsForWorktree(worktreePath, options)
})
return Number.parseInt(stdout.trim(), 10) || 0
): Promise<{ ahead: number; behind: number }> {
const { stdout } = await gitExecFileAsync(
['rev-list', '--left-right', '--count', `${baseOid}...${headOid}`],
{ ...gitOptionsForWorktree(worktreePath, options) }
)
const [behind = '', ahead = ''] = stdout.trim().split(/\s+/)
return {
ahead: Number.parseInt(ahead, 10) || 0,
behind: Number.parseInt(behind, 10) || 0
}
}
+30 -4
View File
@@ -124,7 +124,7 @@ describe('getBranchCompare', () => {
nameStatus: 'M\tfile-a.ts\nR100\told-name.ts\tnew-name.ts\nC100\told-copy.ts\tnew-copy.ts\n',
numstat:
'10\t2\tfile-a.ts\n1\t1\told-name.ts => new-name.ts\n3\t0\told-copy.ts => new-copy.ts\n',
revList: '7\n'
revList: '4\t7\n'
})
const result = await getBranchCompare('/repo', 'origin/main')
@@ -137,6 +137,7 @@ describe('getBranchCompare', () => {
mergeBase: 'merge-base-oid',
changedFiles: 3,
commitsAhead: 7,
commitsBehind: 4,
status: 'ready'
})
expect(result.entries).toEqual([
@@ -146,6 +147,30 @@ describe('getBranchCompare', () => {
])
})
// Why: a rebased branch is ahead AND behind; the symmetric --left-right range is the
// only form that reports both, and its columns are left=behind, right=ahead.
it('counts both divergence directions from one symmetric rev-list', async () => {
mockBranchCompareGit({
branch: 'main\n',
probe: { 'refs/remotes/origin/main^{commit}': 'base-oid\n' },
headOid: 'head-oid\n',
baseOid: 'base-oid\n',
mergeBase: 'merge-base-oid\n',
nameStatus: '',
numstat: '',
revList: '12\t33\n'
})
const result = await getBranchCompare('/repo', 'origin/main')
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['rev-list', '--left-right', '--count', 'base-oid...head-oid'],
expect.objectContaining({ cwd: '/repo' })
)
expect(result.summary.commitsAhead).toBe(33)
expect(result.summary.commitsBehind).toBe(12)
})
it('returns invalid-base when the compare ref does not resolve', async () => {
mockBranchCompareGit({
branch: 'main\n',
@@ -198,6 +223,7 @@ describe('getBranchCompare', () => {
mergeBase: null,
changedFiles: 0,
commitsAhead: 0,
commitsBehind: 0,
status: 'ready'
})
expect(result.entries).toEqual([])
@@ -228,7 +254,7 @@ describe('getBranchCompare', () => {
mergeBase: 'merge-base-oid\n',
nameStatus: 'M\tdocs/日本語/sample.md\n',
numstat: '2\t1\tdocs/日本語/sample.md\n',
revList: '1\n'
revList: '0\t1\n'
})
const result = await getBranchCompare('/repo', 'origin/main')
@@ -282,7 +308,7 @@ describe('getBranchCompare', () => {
mergeBase: 'merge-base-oid\n',
nameStatus: '',
numstat: '',
revList: '0\n'
revList: '0\t0\n'
})
const result = await getBranchCompare('/repo', 'origin/main')
@@ -303,7 +329,7 @@ describe('getBranchCompare', () => {
mergeBase: 'merge-base-oid\n',
nameStatus: '',
numstat: '',
revList: '0\n'
revList: '0\t0\n'
})
const result = await getBranchCompare('/repo', 'refs/remotes/origin/main')
+8 -2
View File
@@ -29,7 +29,7 @@ describe('relay branchCompare', () => {
return Promise.resolve({ stdout: 'merge-base\n', stderr: '' })
}
if (args[0] === 'rev-list') {
return Promise.resolve({ stdout: '1\n', stderr: '' })
return Promise.resolve({ stdout: '2\t1\n', stderr: '' })
}
throw new Error(`Unexpected git command: ${args.join(' ')}`)
})
@@ -52,7 +52,13 @@ describe('relay branchCompare', () => {
changes.resolve([{ path: 'file.ts' }])
await expect(pending).resolves.toMatchObject({
summary: { compareRef: 'feature', changedFiles: 1, commitsAhead: 1, status: 'ready' }
summary: {
compareRef: 'feature',
changedFiles: 1,
commitsAhead: 1,
commitsBehind: 2,
status: 'ready'
}
})
})
})
+5 -2
View File
@@ -182,6 +182,7 @@ export async function branchCompare(
// compare error makes the source-control panel look broken.
summary.changedFiles = 0
summary.commitsAhead = 0
summary.commitsBehind = 0
summary.status = 'ready'
return { summary, entries: [] }
}
@@ -215,10 +216,12 @@ export async function branchCompare(
try {
const [entries, { stdout: countOut }] = await Promise.all([
loadBranchChanges(mergeBase, headOid),
git(['rev-list', '--count', `${baseOid}..${headOid}`], worktreePath)
git(['rev-list', '--left-right', '--count', `${baseOid}...${headOid}`], worktreePath)
])
summary.changedFiles = entries.length
summary.commitsAhead = Number.parseInt(countOut.trim(), 10) || 0
const [behindOut = '', aheadOut = ''] = countOut.trim().split(/\s+/)
summary.commitsAhead = Number.parseInt(aheadOut, 10) || 0
summary.commitsBehind = Number.parseInt(behindOut, 10) || 0
summary.status = 'ready'
return { summary, entries }
} catch (error) {
@@ -208,6 +208,44 @@ describe('SourceControlBranchContextRow', () => {
expect(markup).toContain('Retry')
})
// A branch rebased onto origin/main still tracks its pre-rebase remote branch,
// so upstream ↑↓ answered a question nobody asked here. One count, against the
// base ref, on the line that names it.
it('counts commits against the compare base, on the base line', () => {
const markup = renderToStaticMarkup(
<SourceControlBranchContextRow
summary={{ ...readySummary, baseRef: 'refs/remotes/origin/main', commitsAhead: 5 }}
compareBaseRef={null}
headDisplay={{ kind: 'branch', branchName: 'feature/rebased' }}
onChangeBaseRef={vi.fn()}
onRetry={vi.fn()}
/>
)
// Anchor on the base-ref button, not on 'origin/main' — the group's
// head→base aria-label repeats the base ref at the top of the markup.
const baseIndex = markup.indexOf('aria-label="Change base ref:')
expect(markup.indexOf('↑5')).toBeGreaterThan(baseIndex)
expect(markup).toContain('aria-label="5 commits ahead of origin/main"')
// Nothing claims the branch is behind — that count is not available.
expect(markup).not.toContain('↓')
})
it('shows the count on the base line when there is no head identity', () => {
const markup = renderToStaticMarkup(
<SourceControlBranchContextRow
summary={{ ...readySummary, commitsAhead: 3 }}
compareBaseRef={null}
headDisplay={null}
onChangeBaseRef={vi.fn()}
onRetry={vi.fn()}
/>
)
expect(markup).toContain('↑3')
expect(markup).toContain('aria-label="3 commits ahead of origin/FRONT-192-ZisVoucherStrip"')
})
it('renders a compact external review link when a manual URL is available', () => {
const markup = renderToStaticMarkup(
<SourceControlBranchContextRow
@@ -319,35 +357,35 @@ describe('SourceControlBranchContextRow branch line total', () => {
}
})
// Lines measure the branch's work, commits measure the comparison, so each sits
// on the line that names its subject. Adjacency is what made them read as one
// number in the first place.
it('puts the chip on the head line, ahead of the base line and its commit count', () => {
const markup = renderWithLineTotal(
{ added: 8259, removed: 670, mergeBase: 'base' },
{
...readySummary,
commitsAhead: 2
}
)
const headIndex = markup.indexOf('data-testid="source-control-head-identity"')
const chipIndex = markup.indexOf('data-testid="source-control-branch-line-total"')
const aheadIndex = markup.indexOf('↑2')
const reviewIndex = markup.indexOf('aria-label="Open review page in browser"')
expect(headIndex).toBeGreaterThan(-1)
expect(chipIndex).toBeGreaterThan(headIndex)
expect(aheadIndex).toBeGreaterThan(chipIndex)
expect(reviewIndex).toBeGreaterThan(aheadIndex)
})
it('keeps the ahead count out of the line-total colors', () => {
// Regression: with no upstream divergence this is the only count left, so
// dropping it left a pushed, rebased branch showing no divergence at all.
it('counts commits against the compare base on the base line', () => {
const markup = renderWithLineTotal(
{ added: 8259, removed: 670, mergeBase: 'base' },
{ ...readySummary, commitsAhead: 2 }
)
// The `↑2` span must carry the muted class, not added-green — two adjacent
// green numbers counting different units is the bug this guards.
expect(markup).toContain('↑2')
expect(markup).toContain('2 commits ahead of origin/FRONT-192-ZisVoucherStrip')
// It belongs to the base line, after the base-ref button.
expect(markup.indexOf('↑2')).toBeGreaterThan(markup.indexOf('aria-label="Change base ref:'))
// Nothing claims the branch is behind its base — that count does not exist.
expect(markup).not.toContain('↓')
})
it('keeps the commit count out of the line-total colors', () => {
const markup = renderToStaticMarkup(
<SourceControlBranchContextRow
summary={{ ...readySummary, commitsAhead: 2 }}
compareBaseRef={null}
headDisplay={{ kind: 'branch', branchName: 'feature/line-total' }}
branchLineTotal={{ added: 8259, removed: 670, mergeBase: 'base' }}
onChangeBaseRef={vi.fn()}
onRetry={vi.fn()}
/>
)
// Two adjacent green numbers counting different units is the bug this guards.
const aheadSpan = markup.slice(
markup.lastIndexOf('<span', markup.indexOf('↑2')),
markup.indexOf('↑2')
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
buildSourceControlBranchContextStats,
buildSourceControlCompareBaseStats,
formatSourceControlRefLabel,
resolveSourceControlDisplayedBaseRef,
shouldShowSourceControlBranchContextChrome,
@@ -62,112 +62,77 @@ describe('source-control branch context stats', () => {
expect(shouldShowSourceControlBranchContextChrome(readySummary, null, null)).toBe(true)
})
it('renders upstream ahead and behind counts against the tracking branch', () => {
const stats = buildSourceControlBranchContextStats({
summary: { ...readySummary, commitsAhead: 0 },
baseRef: 'origin/main',
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 2,
behind: 1
}
})
expect(stats.map((stat) => stat.label)).toEqual(['↑2', '↓1'])
expect(stats[0]?.title).toBe('2 commits ahead of origin/feature')
expect(stats[1]?.title).toBe('1 commit behind origin/feature')
it('counts commits ahead of the compare base', () => {
const stats = buildSourceControlCompareBaseStats(readySummary, 'refs/remotes/origin/main')
expect(stats.map((stat) => stat.label)).toEqual(['\u21913'])
expect(stats[0]?.title).toBe('3 commits ahead of origin/main')
})
it('shows both upstream and compare ahead when counts match but targets differ', () => {
const stats = buildSourceControlBranchContextStats({
summary: readySummary,
baseRef: 'origin/main',
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 3,
behind: 0
}
})
expect(stats.map((stat) => ({ key: stat.key, label: stat.label, title: stat.title }))).toEqual([
{
key: 'upstream-ahead',
label: '↑3',
title: '3 commits ahead of origin/feature'
},
{
key: 'compare-ahead',
label: '↑3',
title: '3 commits ahead of origin/main'
}
it('names the base ref, never the tracked branch', () => {
// The rebase case: upstream still points at the pre-rebase branch. This count
// is against the compare base and says so, so the two cannot be conflated.
const stats = buildSourceControlCompareBaseStats(
{ ...readySummary, commitsAhead: 36 },
'origin/main'
)
expect(stats[0]?.label).toBe('\u219136')
expect(stats[0]?.title).toBe('36 commits ahead of origin/main')
})
it('singularizes a single commit', () => {
const stats = buildSourceControlCompareBaseStats(
{ ...readySummary, commitsAhead: 1, commitsBehind: 1 },
'origin/main'
)
expect(stats.map((stat) => stat.title)).toEqual([
'1 commit ahead of origin/main',
'1 commit behind origin/main'
])
})
it('shows branch-compare ahead when it differs from upstream ahead', () => {
const stats = buildSourceControlBranchContextStats({
summary: readySummary,
baseRef: 'origin/main',
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 1,
behind: 0
}
})
expect(stats.map((stat) => stat.label)).toEqual(['↑1', '↑3'])
expect(stats[0]?.title).toBe('1 commit ahead of origin/feature')
expect(stats[1]?.title).toBe('3 commits ahead of origin/main')
// The case the row exists for: a rebased branch is ahead of its base and behind it.
it('counts both directions against the compare base', () => {
const stats = buildSourceControlCompareBaseStats(
{ ...readySummary, commitsAhead: 33, commitsBehind: 12 },
'refs/remotes/origin/main'
)
expect(stats.map((stat) => stat.label)).toEqual(['\u219133', '\u219312'])
expect(stats[1]?.title).toBe('12 commits behind origin/main')
})
it('dedupes branch-compare ahead only when target and count match upstream', () => {
const stats = buildSourceControlBranchContextStats({
summary: { ...readySummary, commitsAhead: 2 },
baseRef: 'origin/main',
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/main',
ahead: 2,
behind: 0
}
})
expect(stats.map((stat) => stat.label)).toEqual(['↑2'])
expect(stats[0]?.key).toBe('upstream-ahead')
expect(stats[0]?.title).toBe('2 commits ahead of origin/main')
})
it('falls back to branch-compare ahead without upstream', () => {
const stats = buildSourceControlBranchContextStats({
summary: readySummary,
baseRef: 'origin/main'
})
expect(stats.map((stat) => stat.label)).toEqual(['↑3'])
expect(stats[0]?.title).toBe('3 commits ahead of origin/main')
})
it('formats namespaced base refs in stat titles', () => {
const stats = buildSourceControlBranchContextStats({
summary: readySummary,
baseRef: 'refs/remotes/origin/main'
})
expect(stats[0]?.title).toBe('3 commits ahead of origin/main')
})
it('falls back to a generic upstream label when upstreamName is missing', () => {
const stats = buildSourceControlBranchContextStats({
summary: { ...readySummary, commitsAhead: 0 },
baseRef: 'origin/main',
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }
})
expect(stats[0]?.title).toBe('2 commits ahead of upstream')
})
it('returns no stats when branch is even with base', () => {
it('drops each direction independently when it is zero or unreported', () => {
expect(
buildSourceControlBranchContextStats({
summary: { ...readySummary, commitsAhead: 0 },
baseRef: 'origin/main',
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }
})
buildSourceControlCompareBaseStats(
{ ...readySummary, commitsAhead: 0, commitsBehind: 4 },
'origin/main'
).map((stat) => stat.label)
).toEqual(['\u21934'])
// An older remote host omits commitsBehind entirely; ahead must still render.
expect(
buildSourceControlCompareBaseStats(
{ ...readySummary, commitsBehind: undefined },
'origin/main'
).map((stat) => stat.label)
).toEqual(['\u21913'])
})
it('shows nothing without a ready summary or a positive count', () => {
expect(buildSourceControlCompareBaseStats(null, 'origin/main')).toEqual([])
expect(buildSourceControlCompareBaseStats(undefined, 'origin/main')).toEqual([])
expect(
buildSourceControlCompareBaseStats({ ...readySummary, status: 'loading' }, 'origin/main')
).toEqual([])
expect(
buildSourceControlCompareBaseStats(
{ ...readySummary, commitsAhead: 0, commitsBehind: 0 },
'origin/main'
)
).toEqual([])
expect(
buildSourceControlCompareBaseStats(
{ ...readySummary, commitsAhead: undefined, commitsBehind: undefined },
'origin/main'
)
).toEqual([])
})
})
@@ -0,0 +1,80 @@
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { SourceControlBranchSection } from './source-control/listing/branch-section'
import type { GitBranchCompareSummary } from '../../../../shared/git-diff-compare-types'
const readySummary: GitBranchCompareSummary = {
baseRef: 'refs/remotes/origin/main',
baseOid: 'base',
compareRef: 'feature',
headOid: 'head',
mergeBase: 'base',
changedFiles: 4,
commitsAhead: 5,
status: 'ready'
}
function render(
summary: GitBranchCompareSummary,
fileCount = 4,
totalBranchEntryCount = fileCount
): string {
return renderToStaticMarkup(
<SourceControlBranchSection
branchSummary={summary}
filteredBranchEntries={Array.from({ length: fileCount }, (_, index) => ({
path: `src/file-${index}.ts`,
status: 'modified'
}))}
totalBranchEntryCount={totalBranchEntryCount}
// Collapsed: the heading is the subject here, not the virtualized list.
collapsedSections={new Set(['branch'])}
toggleSection={vi.fn()}
sourceControlViewMode="list"
visibleBranchTreeRows={[]}
fileListScrollElement={null}
collapsedTreeDirs={new Set()}
toggleTreeDir={vi.fn()}
currentWorktreeId="wt-1"
worktreePath={join(tmpdir(), 'wt-1')}
revealInExplorer={vi.fn()}
activeConnectionId={null}
openCommittedDiff={vi.fn()}
openBranchAllDiffs={vi.fn()}
diffCommentCountByPath={new Map()}
/>
)
}
describe('SourceControlBranchSection heading', () => {
// The heading counts files that differ from the compare base, not every file
// the branch ever touched — a rebased branch makes those read alike.
it('names the compare base on the count, leaving the heading text alone', () => {
const markup = render(readySummary)
expect(markup).toContain('Committed on Branch')
expect(markup).toContain('title="4 files changed vs origin/main"')
// No aria-label: inside the section toggle button it would rewrite the
// button's accessible name ("Committed on Branch 4 files changed vs …").
expect(markup).not.toContain('aria-label="4 files changed vs origin/main"')
})
it('uses the singular count label for one file', () => {
expect(render(readySummary, 1)).toContain('title="1 file changed vs origin/main"')
})
// A narrowing filter changes what the number means: 2 matching files are not
// "2 files changed vs origin/main", so the label must go silent.
it('leaves the count unlabelled while a filter narrows the list', () => {
expect(render(readySummary, 2, 4)).not.toContain('files changed vs')
})
it('leaves the count unlabelled when the summary has no base ref', () => {
const markup = render({ ...readySummary, baseRef: ' ' })
expect(markup).toContain('Committed on Branch')
expect(markup).not.toContain('files changed vs')
})
})
@@ -10,12 +10,14 @@ import type { SourceControlTreeNode } from '../../source-control-tree'
import type { SourceControlRowOpenEvent } from './split-open'
import { BranchEntryRow } from './branch-entry-row'
import { SectionHeader } from './section-header'
import { formatSourceControlRefLabel } from '../panel/branch-context-stats'
import { SourceControlBranchTreeDirectoryRow } from './tree-directory-rows'
import { SourceControlVirtualFileList } from './virtual-file-list'
export function SourceControlBranchSection({
branchSummary,
filteredBranchEntries,
totalBranchEntryCount,
collapsedSections,
toggleSection,
sourceControlViewMode,
@@ -33,6 +35,7 @@ export function SourceControlBranchSection({
}: {
branchSummary: GitBranchCompareSummary
filteredBranchEntries: GitBranchChangeEntry[]
totalBranchEntryCount: number
collapsedSections: Set<string>
toggleSection: (section: string) => void
sourceControlViewMode: SourceControlViewMode
@@ -52,6 +55,27 @@ export function SourceControlBranchSection({
) => void
diffCommentCountByPath: Map<string, number>
}): React.JSX.Element {
const baseRef = branchSummary.baseRef?.trim()
const fileCount = filteredBranchEntries.length
// Why: the heading counts files that differ from the compare base, not every
// file the branch ever touched — a rebased branch makes the two read alike.
// A narrowing filter changes what the number means, so the label goes silent
// rather than claim the filtered count is the branch total.
const countTitle =
baseRef && fileCount === totalBranchEntryCount
? fileCount === 1
? translate(
'auto.components.right.sidebar.SourceControl.branchFilesChangedVsBaseOne',
'1 file changed vs {{ref}}',
{ ref: formatSourceControlRefLabel(baseRef) }
)
: translate(
'auto.components.right.sidebar.SourceControl.branchFilesChangedVsBaseOther',
'{{count}} files changed vs {{ref}}',
{ count: fileCount, ref: formatSourceControlRefLabel(baseRef) }
)
: undefined
return (
<div>
<SectionHeader
@@ -59,7 +83,8 @@ export function SourceControlBranchSection({
'auto.components.right.sidebar.SourceControl.d7ae61269b',
'Committed on Branch'
)}
count={filteredBranchEntries.length}
count={fileCount}
countTitle={countTitle}
isCollapsed={collapsedSections.has('branch')}
onToggle={() => toggleSection('branch')}
actions={
@@ -6,6 +6,7 @@ import { translate } from '@/i18n/i18n'
export function SectionHeader({
label,
count,
countTitle,
conflictCount = 0,
isCollapsed,
onToggle,
@@ -13,6 +14,8 @@ export function SectionHeader({
}: {
label: string
count: number
/** Spells out what the count measures — e.g. files changed against a compare base. */
countTitle?: string
conflictCount?: number
isCollapsed: boolean
onToggle: () => void
@@ -31,7 +34,11 @@ export function SectionHeader({
className={cn('size-3.5 shrink-0 transition-transform', isCollapsed && '-rotate-90')}
/>
<span>{label}</span>
<span className="text-[11px] font-medium tabular-nums">{count}</span>
{/* Why: no aria-label here — inside the toggle button it would rewrite the
button's accessible name; the explanation stays a hover-only title. */}
<span className="text-[11px] font-medium tabular-nums" title={countTitle}>
{count}
</span>
{conflictCount > 0 && (
<span className="text-[11px] font-medium text-destructive/80">
· {conflictCount}{' '}
@@ -1,10 +1,7 @@
import React from 'react'
import { ExternalLink, Loader2, RefreshCw } from 'lucide-react'
import type { GitBranchCompareSummary } from '../../../../../../shared/git-diff-compare-types'
import type {
GitBranchLineTotal,
GitUpstreamStatus
} from '../../../../../../shared/git-status-types'
import type { GitBranchLineTotal } from '../../../../../../shared/git-status-types'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
@@ -13,9 +10,10 @@ import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-dis
import { SourceControlHeaderIconButton } from './header-icon-button'
import { SourceControlBranchLineTotalChip } from './branch-line-total-chip'
import {
buildSourceControlBranchContextStats,
buildSourceControlCompareBaseStats,
formatSourceControlRefLabel,
resolveSourceControlDisplayedBaseRef
resolveSourceControlDisplayedBaseRef,
type SourceControlBranchContextStat
} from './branch-context-stats'
function BaseRefButton({
@@ -47,24 +45,20 @@ function BaseRefButton({
)
}
function ContextStat({
stat
}: {
stat: ReturnType<typeof buildSourceControlBranchContextStats>[number]
}): React.JSX.Element {
const className = cn(
'shrink-0 tabular-nums text-muted-foreground',
stat.tone === 'muted' && 'text-muted-foreground/70'
)
if (!stat.title) {
return <span className={className}>{stat.label}</span>
}
function ContextStat({ stat }: { stat: SourceControlBranchContextStat }): React.JSX.Element {
// Why: aria-label on an unfocusable span is never announced, so the ref this
// count measures against would exist only for sighted hover users. tabIndex also
// lets keyboard users open the tooltip, like HeadIdentity.
return (
<Tooltip>
<TooltipTrigger asChild>
<span className={className}>{stat.label}</span>
<span
className="shrink-0 rounded-sm tabular-nums text-muted-foreground/70 outline-none focus-visible:ring-1 focus-visible:ring-ring"
tabIndex={0}
aria-label={stat.title}
>
{stat.label}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{stat.title}
@@ -286,7 +280,6 @@ export function SourceControlBranchContextRow({
summary,
compareBaseRef,
headDisplay = null,
upstreamStatus,
manualReviewUrl,
branchLineTotal,
onChangeBaseRef,
@@ -295,7 +288,6 @@ export function SourceControlBranchContextRow({
summary: GitBranchCompareSummary | null
compareBaseRef: string | null
headDisplay?: WorktreeGitIdentityDisplay | null
upstreamStatus?: GitUpstreamStatus
manualReviewUrl?: string | null
branchLineTotal?: GitBranchLineTotal | null
onChangeBaseRef: () => void
@@ -392,23 +384,8 @@ export function SourceControlBranchContextRow({
)
}
const stats = buildSourceControlBranchContextStats({
summary,
baseRef: displayedBaseRef,
upstreamStatus
})
const trailing = (
<>
{stats.length > 0 ? (
<span className="inline-flex shrink-0 items-center gap-1.5">
{stats.map((stat) => (
<ContextStat key={stat.key} stat={stat} />
))}
</span>
) : null}
<ManualReviewLinkButton url={manualReviewUrl} />
</>
const compareStatNodes = buildSourceControlCompareBaseStats(summary, displayedBaseRef).map(
(stat) => <ContextStat key={stat.key} stat={stat} />
)
return (
@@ -419,7 +396,12 @@ export function SourceControlBranchContextRow({
baseLabel={baseLabel}
onChangeBaseRef={onChangeBaseRef}
changeBaseTitle={changeBaseTitle}
trailing={trailing}
trailing={
<>
{compareStatNodes}
<ManualReviewLinkButton url={manualReviewUrl} />
</>
}
headTrailing={<SourceControlBranchLineTotalChip branchLineTotal={branchLineTotal} />}
/>
</CompareFlowGroup>
@@ -1,45 +1,42 @@
import type { GitBranchCompareSummary } from '../../../../../../shared/git-diff-compare-types'
import type { GitUpstreamStatus } from '../../../../../../shared/git-status-types'
import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
import { translate } from '@/i18n/i18n'
function formatAheadOfBaseTitle(count: number, baseRef: string): string {
function formatAheadOfTitle(count: number, ref: string): string {
return count === 1
? translate(
'auto.components.right.sidebar.SourceControl.f9b2441bb6',
'1 commit ahead of {{value0}}',
{ value0: baseRef }
'auto.components.right.sidebar.SourceControl.compareBaseCommitsAheadOne',
'1 commit ahead of {{ref}}',
{ ref }
)
: translate(
'auto.components.right.sidebar.SourceControl.b715ef615b',
'{{value0}} commits ahead of {{value1}}',
{ value0: count, value1: baseRef }
'auto.components.right.sidebar.SourceControl.compareBaseCommitsAheadOther',
'{{count}} commits ahead of {{ref}}',
{ count, ref }
)
}
function formatBehindBaseTitle(count: number, baseRef: string): string {
function formatBehindOfTitle(count: number, ref: string): string {
return count === 1
? translate(
'auto.components.right.sidebar.SourceControl.c1a8f3e204',
'1 commit behind {{value0}}',
{ value0: baseRef }
'auto.components.right.sidebar.SourceControl.compareBaseCommitsBehindOne',
'1 commit behind {{ref}}',
{ ref }
)
: translate(
'auto.components.right.sidebar.SourceControl.d2b9g4f315',
'{{value0}} commits behind {{value1}}',
{ value0: count, value1: baseRef }
'auto.components.right.sidebar.SourceControl.compareBaseCommitsBehindOther',
'{{count}} commits behind {{ref}}',
{ count, ref }
)
}
// Why: ahead/behind carry no color of their own. Green and red are reserved for
// the line-total chip that sits beside them — an `↑1` in added-green next to
// `+1,114` reads as one quantity when they count different things (commits vs
// lines). The ↑/↓ glyph already carries direction.
// Why: the counts carry no color of their own. Green and red are reserved for the
// line-total chip beside them — an `↑1` in added-green next to `+1,114` reads as one
// quantity when they count different things (commits vs lines).
export type SourceControlBranchContextStat = {
key: string
label: string
title?: string
tone: 'default' | 'muted'
title: string
}
export function resolveSourceControlDisplayedBaseRef(
@@ -83,71 +80,32 @@ export function shouldShowSourceControlBranchContextChrome(
return shouldShowSourceControlBranchContextRow(summary, compareBaseRef) || headDisplay != null
}
function resolveUpstreamDisplayLabel(upstreamStatus: GitUpstreamStatus): string {
const named = upstreamStatus.upstreamName?.trim()
if (named) {
return formatSourceControlRefLabel(named)
}
return translate('auto.components.right.sidebar.SourceControl.f3a1b8c204', 'upstream')
}
export function buildSourceControlBranchContextStats({
summary,
baseRef,
upstreamStatus
}: {
summary: GitBranchCompareSummary
// Why: both directions, on the line that names the ref they measure. Ahead alone hid
// the case this row exists for — a rebased branch that has also fallen behind its base.
export function buildSourceControlCompareBaseStats(
summary: GitBranchCompareSummary | null | undefined,
baseRef: string
upstreamStatus?: GitUpstreamStatus
}): SourceControlBranchContextStat[] {
if (summary.status !== 'ready') {
): SourceControlBranchContextStat[] {
if (summary?.status !== 'ready') {
return []
}
const stats: SourceControlBranchContextStat[] = []
const baseLabel = formatSourceControlRefLabel(baseRef)
const hasUpstream = Boolean(upstreamStatus?.hasUpstream)
const upstreamLabel =
hasUpstream && upstreamStatus ? resolveUpstreamDisplayLabel(upstreamStatus) : null
if (hasUpstream && upstreamStatus && upstreamLabel != null) {
if (upstreamStatus.ahead > 0) {
stats.push({
key: 'upstream-ahead',
label: `${upstreamStatus.ahead}`,
title: formatAheadOfBaseTitle(upstreamStatus.ahead, upstreamLabel),
tone: 'muted'
})
}
if (upstreamStatus.behind > 0) {
stats.push({
key: 'upstream-behind',
label: `${upstreamStatus.behind}`,
title: formatBehindBaseTitle(upstreamStatus.behind, upstreamLabel),
tone: 'muted'
})
}
}
const stats: SourceControlBranchContextStat[] = []
const commitsAhead = summary.commitsAhead
if (typeof commitsAhead === 'number' && commitsAhead > 0) {
// Why: only collapse compare-ahead into upstream-ahead when both describe the
// same ref and count — equal numbers against different targets must both show.
const sameTargetAsUpstream =
hasUpstream &&
upstreamStatus != null &&
upstreamLabel != null &&
upstreamLabel === baseLabel &&
commitsAhead === upstreamStatus.ahead
if (!sameTargetAsUpstream) {
stats.push({
key: 'compare-ahead',
label: `${commitsAhead}`,
title: formatAheadOfBaseTitle(commitsAhead, baseLabel),
tone: 'muted'
})
}
stats.push({
key: 'compare-ahead',
label: `${commitsAhead}`,
title: formatAheadOfTitle(commitsAhead, baseLabel)
})
}
const commitsBehind = summary.commitsBehind
if (typeof commitsBehind === 'number' && commitsBehind > 0) {
stats.push({
key: 'compare-behind',
label: `${commitsBehind}`,
title: formatBehindOfTitle(commitsBehind, baseLabel)
})
}
return stats
}
@@ -1,10 +1,7 @@
import React, { useCallback, useEffect, useRef } from 'react'
import { GitPullRequestArrow, Loader2, Search, X } from 'lucide-react'
import type { GitBranchCompareSummary } from '../../../../../../shared/git-diff-compare-types'
import type {
GitBranchLineTotal,
GitUpstreamStatus
} from '../../../../../../shared/git-status-types'
import type { GitBranchLineTotal } from '../../../../../../shared/git-status-types'
import type { SourceControlViewMode } from '../../../../../../shared/ui-chrome-types'
import type { HostedReviewInfo } from '../../../../../../shared/hosted-review'
import type { PrimaryAction } from '../../source-control-primary-action'
@@ -40,7 +37,6 @@ type SourceControlHeaderToolbarProps = {
branchSummary: GitBranchCompareSummary | null
compareBaseRef: string | null
headDisplay?: WorktreeGitIdentityDisplay | null
upstreamStatus?: GitUpstreamStatus
manualReviewUrl?: string | null
branchLineTotal?: GitBranchLineTotal | null
}
@@ -147,7 +143,6 @@ export function SourceControlHeaderToolbar({
branchSummary,
compareBaseRef,
headDisplay = null,
upstreamStatus,
manualReviewUrl,
branchLineTotal
}: SourceControlHeaderToolbarProps): React.JSX.Element {
@@ -294,7 +289,6 @@ export function SourceControlHeaderToolbar({
summary={branchSummary}
compareBaseRef={compareBaseRef}
headDisplay={headDisplay}
upstreamStatus={upstreamStatus}
manualReviewUrl={manualReviewUrl}
branchLineTotal={branchLineTotal}
onChangeBaseRef={onChangeBaseRef}
@@ -189,6 +189,7 @@ export function SourceControlPanelContent(props: SourceControlPanelReadyProps) {
<SourceControlBranchSection
branchSummary={branchSummary}
filteredBranchEntries={filteredBranchEntries}
totalBranchEntryCount={branchEntries.length}
collapsedSections={collapsedSections}
toggleSection={toggleSection}
sourceControlViewMode={sourceControlViewMode}
@@ -40,7 +40,6 @@ export function SourceControlPanelReady(props: SourceControlPanelReadyProps) {
openHostedReviewInChecks,
prGenerating,
refreshBranchCompare,
remoteStatus,
selectedKeys,
setBaseRefDialogOpen,
setDiffCommentsExpanded,
@@ -84,7 +83,6 @@ export function SourceControlPanelReady(props: SourceControlPanelReadyProps) {
branchLineTotal={branchLineTotal}
compareBaseRef={compareBaseRef}
headDisplay={gitIdentityDisplay}
upstreamStatus={remoteStatus}
manualReviewUrl={manualReviewUrl}
/>
+6 -4
View File
@@ -11862,6 +11862,8 @@
"7a09d7f9d2": "base",
"383cf92c73": "tree",
"d7ae61269b": "Committed on Branch",
"branchFilesChangedVsBaseOne": "1 file changed vs {{ref}}",
"branchFilesChangedVsBaseOther": "{{count}} files changed vs {{ref}}",
"48a003c1b1": "Staged Changes",
"d4ef4bafc5": "Changes",
"522f44dce5": "Untracked Files",
@@ -11936,10 +11938,10 @@
"b3c8f1a902": "Filter files by name",
"d4f8c2a901": "Clear and close filter",
"e8a1c4b203": "vs",
"f9b2441bb6": "1 commit ahead of {{value0}}",
"b715ef615b": "{{value0}} commits ahead of {{value1}}",
"c1a8f3e204": "1 commit behind {{value0}}",
"d2b9g4f315": "{{value0}} commits behind {{value1}}",
"compareBaseCommitsAheadOne": "1 commit ahead of {{ref}}",
"compareBaseCommitsAheadOther": "{{count}} commits ahead of {{ref}}",
"compareBaseCommitsBehindOne": "1 commit behind {{ref}}",
"compareBaseCommitsBehindOther": "{{count}} commits behind {{ref}}",
"4b4a7de138": "Open review page in browser",
"createPrIntentCommitBlockedSummary": "Commit blocked: {{value0}} Fix the issue, then retry Create PR.",
"pushRecovery": {
+6 -4
View File
@@ -10589,6 +10589,8 @@
"7a09d7f9d2": "base",
"383cf92c73": "árbol",
"d7ae61269b": "Commits en la rama",
"branchFilesChangedVsBaseOne": "1 archivo cambiado respecto a {{ref}}",
"branchFilesChangedVsBaseOther": "{{count}} archivos cambiados respecto a {{ref}}",
"48a003c1b1": "Cambios preparados",
"d4ef4bafc5": "Cambios",
"522f44dce5": "Archivos sin seguimiento",
@@ -10663,10 +10665,10 @@
"b3c8f1a902": "Filtrar archivos por nombre",
"d4f8c2a901": "Limpiar y cerrar filtro",
"e8a1c4b203": "vs",
"f9b2441bb6": "1 commit por delante de {{value0}}",
"b715ef615b": "{{value0}} commits por delante de {{value1}}",
"c1a8f3e204": "1 commit por detrás de {{value0}}",
"d2b9g4f315": "{{value0}} commits por detrás de {{value1}}",
"compareBaseCommitsAheadOne": "1 commit por delante de {{ref}}",
"compareBaseCommitsAheadOther": "{{count}} commits por delante de {{ref}}",
"compareBaseCommitsBehindOne": "1 commit por detrás de {{ref}}",
"compareBaseCommitsBehindOther": "{{count}} commits por detrás de {{ref}}",
"4b4a7de138": "Abrir página de revisión en el navegador",
"createPrIntentCommitBlockedSummary": "Commit bloqueado: {{value0}} Corrige el issue y reintenta crear PR.",
"pushRecovery": {
+6 -4
View File
@@ -10589,6 +10589,8 @@
"7a09d7f9d2": "ベース",
"383cf92c73": "木",
"d7ae61269b": "コミット先のブランチ",
"branchFilesChangedVsBaseOne": "{{ref}} と比べて 1 ファイルが変更されています",
"branchFilesChangedVsBaseOther": "{{ref}} と比べて {{count}} ファイルが変更されています",
"48a003c1b1": "ステージ済みの変更",
"d4ef4bafc5": "変更点",
"522f44dce5": "追跡されていないファイル",
@@ -10663,10 +10665,10 @@
"b3c8f1a902": "ファイル名でフィルター",
"d4f8c2a901": "フィルターをクリアして閉じる",
"e8a1c4b203": "vs",
"f9b2441bb6": "{{value0}} より 1 コミット進んでいます",
"b715ef615b": "{{value1}}より{{value0}}コミット進んでいます",
"c1a8f3e204": "{{value0}} より 1 コミット遅れています",
"d2b9g4f315": "{{value1}}より{{value0}}コミット遅れています",
"compareBaseCommitsAheadOne": "{{ref}} より 1 コミット進んでいます",
"compareBaseCommitsAheadOther": "{{ref}}より{{count}}コミット進んでいます",
"compareBaseCommitsBehindOne": "{{ref}} より 1 コミット遅れています",
"compareBaseCommitsBehindOther": "{{ref}}より{{count}}コミット遅れています",
"4b4a7de138": "ブラウザでレビューページを開く",
"createPrIntentCommitBlockedSummary": "コミットがブロックされました:{{value0}} Issue を修正して、PR 作成を再試行してください。",
"pushRecovery": {
+6 -4
View File
@@ -10610,6 +10610,8 @@
"7a09d7f9d2": "베이스",
"383cf92c73": "트리",
"d7ae61269b": "브랜치에 Commit 됨",
"branchFilesChangedVsBaseOne": "{{ref}} 대비 파일 1개 변경됨",
"branchFilesChangedVsBaseOther": "{{ref}} 대비 파일 {{count}}개 변경됨",
"48a003c1b1": "스테이징된 변경 사항",
"d4ef4bafc5": "변경 사항",
"522f44dce5": "추적되지 않은 파일",
@@ -10684,10 +10686,10 @@
"b3c8f1a902": "파일 이름으로 필터링",
"d4f8c2a901": "필터 지우고 닫기",
"e8a1c4b203": "vs",
"f9b2441bb6": "{{value0}}보다 1 commit 앞서 있음",
"b715ef615b": "{{value1}}보다 {{value0}} commits 앞서 있음",
"c1a8f3e204": "{{value0}}보다 1 commit 뒤처짐",
"d2b9g4f315": "{{value1}}보다 {{value0}} commits 뒤처짐",
"compareBaseCommitsAheadOne": "{{ref}}보다 1 commit 앞서 있음",
"compareBaseCommitsAheadOther": "{{ref}}보다 {{count}} commits 앞서 있음",
"compareBaseCommitsBehindOne": "{{ref}}보다 1 commit 뒤처짐",
"compareBaseCommitsBehindOther": "{{ref}}보다 {{count}} commits 뒤처짐",
"4b4a7de138": "브라우저에서 검토 페이지 열기",
"createPrIntentCommitBlockedSummary": "커밋이 차단됨: {{value0}} 이슈를 수정한 후 PR 만들기를 다시 시도하세요.",
"pushRecovery": {
+6 -4
View File
@@ -10647,6 +10647,8 @@
"7a09d7f9d2": "根据",
"383cf92c73": "树",
"d7ae61269b": "已提交的更改",
"branchFilesChangedVsBaseOne": "与 {{ref}} 相比更改了 1 个文件",
"branchFilesChangedVsBaseOther": "与 {{ref}} 相比更改了 {{count}} 个文件",
"48a003c1b1": "已暂存的更改",
"d4ef4bafc5": "更改",
"522f44dce5": "未跟踪文件",
@@ -10721,10 +10723,10 @@
"b3c8f1a902": "按文件名筛选",
"d4f8c2a901": "清除并关闭筛选器",
"e8a1c4b203": "vs",
"f9b2441bb6": "领先 {{value0}} 1 个提交",
"b715ef615b": "领先 {{value1}} {{value0}} 个提交",
"c1a8f3e204": "落后 {{value0}} 1 个提交",
"d2b9g4f315": "落后 {{value1}} {{value0}} 个提交",
"compareBaseCommitsAheadOne": "领先 {{ref}} 1 个提交",
"compareBaseCommitsAheadOther": "领先 {{ref}} {{count}} 个提交",
"compareBaseCommitsBehindOne": "落后 {{ref}} 1 个提交",
"compareBaseCommitsBehindOther": "落后 {{ref}} {{count}} 个提交",
"4b4a7de138": "在浏览器中打开审查页面",
"createPrIntentCommitBlockedSummary": "提交被阻止:{{value0}} 修复问题后重试创建 PR。",
"pushRecovery": {
+1
View File
@@ -20,6 +20,7 @@ export type GitBranchCompareSummary = {
mergeBase: string | null
changedFiles: number
commitsAhead?: number
commitsBehind?: number
status: 'ready' | 'invalid-base' | 'unborn-head' | 'no-merge-base' | 'loading' | 'error'
errorMessage?: string
}
+27 -8
View File
@@ -1,4 +1,4 @@
import type { TestInfo } from '@stablyai/playwright-test'
import type { Page, TestInfo } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { waitForActiveWorktree, waitForSessionReady, getActiveTabId } from './helpers/store'
import {
@@ -19,6 +19,25 @@ import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connecti
const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1'
const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500
async function terminalTailContains(page: Page, marker: string): Promise<boolean> {
return page.evaluate((expected) => {
const tabId = window.__store?.getState().activeTabId
const manager = tabId ? window.__paneManagers?.get(tabId) : undefined
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
const buffer = pane?.terminal?.buffer?.active
if (!buffer) {
return false
}
const firstRow = Math.max(0, buffer.length - 200)
for (let row = buffer.length - 1; row >= firstRow; row -= 1) {
if (buffer.getLine(row)?.translateToString(true).includes(expected) === true) {
return true
}
}
return false
}, marker)
}
test.use({
seedTestRepo: false,
orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) }
@@ -60,11 +79,11 @@ test.describe('SSH terminal hidden view parking', () => {
`for i in $(seq 1 200); do echo "${marker}_$i:"; done\r`
)
await expect
.poll(() => getTerminalContent(orcaPage, 20_000), {
.poll(() => terminalTailContains(orcaPage, `${marker}_200:`), {
timeout: 30_000,
message: 'SSH marker output did not render before parking'
})
.toContain(`${marker}_200:`)
.toBe(true)
// Why the pad: ~3000 × ~60B ≈ 180KB pushes the early markers past the
// relay's 100KiB rolling replay buffer while staying inside main's
// ~5k-row headless model — so a revealed `${marker}_1:` can only have
@@ -72,14 +91,14 @@ test.describe('SSH terminal hidden view parking', () => {
await sendToTerminal(
orcaPage,
sshPtyId,
`for i in $(seq 1 3000); do echo "PAD_$i:0123456789012345678901234567890123456789"; done; echo "${marker}_PAD_DONE:"\r`
`for i in $(seq 1 3000); do echo "PAD_$i:0123456789012345678901234567890123456789"; done; printf '%s%s\\n' "${marker}" "_PAD_DONE:"\r`
)
await expect
.poll(() => getTerminalContent(orcaPage, 20_000), {
.poll(() => terminalTailContains(orcaPage, `${marker}_PAD_DONE:`), {
timeout: 60_000,
message: 'SSH pad output did not finish before parking'
})
.toContain(`${marker}_PAD_DONE:`)
.toBe(true)
await parkHiddenTabBehindDecoy(orcaPage, remote.worktreeId, sshTabId, {
parkDelayMs: PARKING_DELAY_MS
@@ -94,11 +113,11 @@ test.describe('SSH terminal hidden view parking', () => {
}, sshTabId)
await waitForActiveTerminalManager(orcaPage, 60_000)
await expect
.poll(() => getTerminalContent(orcaPage, 20_000), {
.poll(() => terminalTailContains(orcaPage, `${marker}_PAD_DONE:`), {
timeout: 60_000,
message: 'revealed SSH tab did not restore the final pad line'
})
.toContain(`${marker}_PAD_DONE:`)
.toBe(true)
// Depth proof: `${marker}_1:` predates >100KiB of later output, so its
// presence after reveal proves the headless-model paint restored
// scrollback the relay replay cannot hold.