diff --git a/src/main/git/source-control/branch-compare.ts b/src/main/git/source-control/branch-compare.ts index 68f6fbea419..734051463d5 100644 --- a/src/main/git/source-control/branch-compare.ts +++ b/src/main/git/source-control/branch-compare.ts @@ -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) { diff --git a/src/main/git/source-control/compare-ref-oids.ts b/src/main/git/source-control/compare-ref-oids.ts index f2172de1c50..d3657e62c7d 100644 --- a/src/main/git/source-control/compare-ref-oids.ts +++ b/src/main/git/source-control/compare-ref-oids.ts @@ -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 { - 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 + } } diff --git a/src/main/git/status-branch-compare.test.ts b/src/main/git/status-branch-compare.test.ts index e5a676898bc..4bd56de553c 100644 --- a/src/main/git/status-branch-compare.test.ts +++ b/src/main/git/status-branch-compare.test.ts @@ -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') diff --git a/src/relay/git-handler-branch-compare.test.ts b/src/relay/git-handler-branch-compare.test.ts index da557637971..38ff6bfc735 100644 --- a/src/relay/git-handler-branch-compare.test.ts +++ b/src/relay/git-handler-branch-compare.test.ts @@ -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' + } }) }) }) diff --git a/src/relay/git-handler-ops.ts b/src/relay/git-handler-ops.ts index 71a678fe042..ba5c9e59699 100644 --- a/src/relay/git-handler-ops.ts +++ b/src/relay/git-handler-ops.ts @@ -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) { 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 945e169ca34..29f8a5bb7b4 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 @@ -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( + + ) + + // 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( + + ) + + 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( { } }) - // 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( + + ) + + // Two adjacent green numbers counting different units is the bug this guards. const aheadSpan = markup.slice( markup.lastIndexOf(' { 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([]) }) }) diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-section-heading.test.tsx b/src/renderer/src/components/right-sidebar/source-control-branch-section-heading.test.tsx new file mode 100644 index 00000000000..d4136c63271 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-branch-section-heading.test.tsx @@ -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( + ({ + 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') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/branch-section.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/branch-section.tsx index b83a1324ba8..ab37458adb7 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/branch-section.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/branch-section.tsx @@ -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 toggleSection: (section: string) => void sourceControlViewMode: SourceControlViewMode @@ -52,6 +55,27 @@ export function SourceControlBranchSection({ ) => void diffCommentCountByPath: Map }): 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 (
toggleSection('branch')} actions={ diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/section-header.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/section-header.tsx index 028f96feac1..bd948649957 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/section-header.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/section-header.tsx @@ -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')} /> {label} - {count} + {/* Why: no aria-label here — inside the toggle button it would rewrite the + button's accessible name; the explanation stays a hover-only title. */} + + {count} + {conflictCount > 0 && ( · {conflictCount}{' '} diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/branch-context-row.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/branch-context-row.tsx index c92e6ff2064..9a44ecaee26 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/branch-context-row.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/panel/branch-context-row.tsx @@ -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[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 {stat.label} - } - +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 ( - {stat.label} + + {stat.label} + {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 ? ( - - {stats.map((stat) => ( - - ))} - - ) : null} - - + const compareStatNodes = buildSourceControlCompareBaseStats(summary, displayedBaseRef).map( + (stat) => ) return ( @@ -419,7 +396,12 @@ export function SourceControlBranchContextRow({ baseLabel={baseLabel} onChangeBaseRef={onChangeBaseRef} changeBaseTitle={changeBaseTitle} - trailing={trailing} + trailing={ + <> + {compareStatNodes} + + + } headTrailing={} /> diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/branch-context-stats.ts b/src/renderer/src/components/right-sidebar/source-control/panel/branch-context-stats.ts index 53480abeceb..0f68fb1a547 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/branch-context-stats.ts +++ b/src/renderer/src/components/right-sidebar/source-control/panel/branch-context-stats.ts @@ -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 } diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/header-toolbar.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/header-toolbar.tsx index 72568f697ff..9339b2aafa3 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/header-toolbar.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/panel/header-toolbar.tsx @@ -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} diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/panel-content.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/panel-content.tsx index 948d72ebcc7..4a0099adec9 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/panel/panel-content.tsx @@ -189,6 +189,7 @@ export function SourceControlPanelContent(props: SourceControlPanelReadyProps) { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6138b873789..ae34fa9dae7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f053ac15ffa..1f5f859af9e 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e0849cae036..d210ca6fa18 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ddb352a584b..300560c340a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 56ee5a976b9..288863841c6 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": { diff --git a/src/shared/git-diff-compare-types.ts b/src/shared/git-diff-compare-types.ts index ba1ee97015f..b4b3e8662a8 100644 --- a/src/shared/git-diff-compare-types.ts +++ b/src/shared/git-diff-compare-types.ts @@ -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 } diff --git a/tests/e2e/ssh-terminal-parking.spec.ts b/tests/e2e/ssh-terminal-parking.spec.ts index 951ad70ddc5..85a45e8b270 100644 --- a/tests/e2e/ssh-terminal-parking.spec.ts +++ b/tests/e2e/ssh-terminal-parking.spec.ts @@ -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 { + 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.