fix(mobile): one bottom action on the PR tab, and conflicts said once (#37231)

The Source Control hub's next-step bar is now the only bottom action on the
PR tab: the tab body takes its footer from the caller, and only the
diff-review dock passes the old bottom action. The legacy Conflicts box is
gone, since the gate row and the bar's Resolve already cover it, so a
conflicting PR no longer says so four times. The bar keeps its reason line
only where its button does not name the blocker.
This commit is contained in:
Kelvin Amoaba
2026-11-01 23:36:51 +00:00
committed by GitHub
parent c550fbca06
commit 56e83655b0
21 changed files with 77 additions and 787 deletions
+10 -23
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { useCallback, useMemo, useRef, useState, type ReactNode } from 'react'
import { ActivityIndicator, ScrollView, Text, View } from 'react-native'
import {
ArrowRight,
@@ -17,14 +17,12 @@ import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { colors } from '../theme/mobile-theme'
import type { PrSidebarData } from '../session/mobile-pr-sidebar-state'
import { isPrSidebarDetailsPlaceholder } from '../session/mobile-pr-sidebar-state'
import { buildResolveConflictsPrompt } from '../session/pr-ai-triage-prompt'
import { prSidebarRenderBranch } from './mobile-pr-sidebar-presentation'
import { ListGroup, ListRow, LIST_LEADING_INSET } from './list/ListGroup'
import { useNow } from '../hooks/use-now'
import { commentRelativeTime } from './pr-sidebar/pr-comment-relative-time'
import { prStateBadge } from './pr-sidebar/pr-checks-presentation'
import { statusColor } from './pr-sidebar/pr-sidebar-status-color'
import { PRConflictingFilesSection } from './pr-sidebar/PRConflictingFilesSection'
import { PrTabEmptyState } from './pr-sidebar/PrTabEmptyState'
import { PrChangesCard, PrDescription } from './pr-sidebar/PrTabOverviewCards'
import { PrTabBottomAction } from './pr-sidebar/PrTabBottomAction'
@@ -53,8 +51,8 @@ type BodyProps = {
/** Opens the Changes segment from the changes card; absent outside the hub. */
onOpenChanges?: () => void
branchCompare?: BranchCompare
/** The bottom action until the hub's next-step bar replaces it. */
showBottomAction?: boolean
/** The dock's bottom action. The hub passes none: its next-step bar is the one bottom action there. */
footer?: ReactNode
}
// The PR tab answers "can I merge, and what's blocking it": identity, gates, changes, description.
@@ -64,7 +62,7 @@ export function MobilePrTabBody({
bottomInset = 0,
onOpenChanges,
branchCompare = null,
showBottomAction = true
footer = null
}: BodyProps) {
useMobileColorScheme()
const t = useMobileTranslation()
@@ -100,7 +98,6 @@ export function MobilePrTabBody({
</View>
)
}
const footer = showBottomAction ? <PrTabBottomAction tab={tab} bottomInset={bottomInset} /> : null
if (branch === 'none') {
return (
<View style={styles.container}>
@@ -150,20 +147,6 @@ function PrTabSections({
}),
[data.checks, data.checksError, data.details, pr, tab.botAuthorOverrides]
)
const conflictsTriage = {
resolveConflicts: () =>
void tab.triage.launch('resolve-conflicts', () =>
buildResolveConflictsPrompt({
prNumber: pr.number,
baseRef: pr.conflictSummary?.baseRef ?? pr.baseRefName ?? null,
files: pr.conflictSummary?.files ?? [],
worktreeId: tab.worktreeId
})
),
isBusy: tab.triage.isBusy('resolve-conflicts'),
availability: tab.triage.availability,
...tab.triage.noticeFor('resolve-conflicts')
}
const openGate = (row: PrGateRow): (() => void) | undefined => {
switch (row.target) {
case 'checks':
@@ -194,7 +177,6 @@ function PrTabSections({
)
})}
</ListGroup>
<PRConflictingFilesSection pr={pr} triage={conflictsTriage} />
<PrChangesCard data={data} onPress={onOpenChanges} />
<PrDescription tab={tab} data={data} />
</>
@@ -359,7 +341,12 @@ export function MobilePRSidebar({ onRetry, bottomInset = 0, ...input }: SidebarP
onClose={() => setMenuOpen(false)}
/>
</View>
<MobilePrTabBody tab={tab} onRetry={onRetry} bottomInset={bottomInset} />
<MobilePrTabBody
tab={tab}
onRetry={onRetry}
bottomInset={bottomInset}
footer={<PrTabBottomAction tab={tab} bottomInset={bottomInset} />}
/>
</>
)}
<MobilePrTabSheets tab={tab} />
@@ -1,101 +0,0 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PRInfo } from '../../../../src/shared/github/pull-request-types'
import { PRConflictingFilesSection } from './PRConflictingFilesSection'
import { buildMergeabilityRefreshCommands } from './pr-conflict-presentation'
const clipboard = vi.hoisted(() => ({ writeText: vi.fn() }))
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: (styles: unknown) => styles },
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({
Check: 'Check',
Copy: 'Copy',
FileWarning: 'FileWarning',
Sparkles: 'Sparkles'
}))
vi.mock('../../platform/clipboard', () => ({ useClipboardWriter: () => clipboard }))
vi.mock('./PRSection', () => ({
PRSection: ({ children }: { children: unknown }) => children
}))
// Refresh-command path requires hosted CONFLICTING with no file list but clean local simulation; copying commands resolves that disagreement.
const PR: Pick<PRInfo, 'mergeable' | 'conflictSummary'> = {
mergeable: 'CONFLICTING',
conflictSummary: {
files: [],
localMergeState: 'clean',
commitsBehind: 2,
baseCommit: 'abc1234',
baseRef: 'main'
}
}
const COMMANDS = buildMergeabilityRefreshCommands()
function press(tree: ReactTestRenderer): Promise<void> {
const control = tree.root
.findAll((node) => node.props.accessibilityLabel === 'Copy mergeability refresh commands')
.at(0)
if (!control) {
throw new Error('the copy control is not rendered')
}
return act(async () => control.props.onPress())
}
function labels(tree: ReactTestRenderer): string[] {
return tree.root
.findAll((node) => typeof node.props.children === 'string')
.flatMap((node) => (typeof node.props.children === 'string' ? [node.props.children] : []))
}
describe('copying the mergeability refresh commands', () => {
let tree: ReactTestRenderer | null = null
beforeEach(() => {
clipboard.writeText.mockReset().mockResolvedValue(undefined)
})
afterEach(() => {
act(() => tree?.unmount())
tree = null
})
async function render(): Promise<ReactTestRenderer> {
let rendered: ReactTestRenderer | null = null
await act(async () => {
rendered = create(createElement(PRConflictingFilesSection, { pr: PR }))
})
if (rendered === null) {
throw new Error('the section did not render')
}
tree = rendered
return rendered
}
it('says it copied when the pasteboard took the commands', async () => {
const rendered = await render()
await press(rendered)
expect(clipboard.writeText).toHaveBeenCalledWith(COMMANDS)
expect(labels(rendered)).toContain('Copied')
})
it('says it failed instead of saying nothing at all', async () => {
// Expose refused clipboard write; otherwise missing shell grant looks indistinguishable from successful copy.
clipboard.writeText.mockRejectedValue(new Error('the clipboard did not accept this text'))
const rendered = await render()
await press(rendered)
expect(labels(rendered)).toContain('Failed to copy text')
expect(labels(rendered)).not.toContain('Copied')
})
})
@@ -1,228 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native'
import { Check, Copy, FileWarning, Sparkles } from 'lucide-react-native'
import { useClipboardWriter } from '../../platform/clipboard'
import { colors } from '../../theme/mobile-theme'
import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme'
import { useMobileTranslation } from '../../i18n/use-mobile-translation'
import type { PRInfo } from '../../../../src/shared/github/pull-request-types'
import { PRSection } from './PRSection'
import { resolveConflictDisplay } from './pr-conflict-presentation'
import { prConflictStyles as styles } from './pr-conflict-styles'
import { prAiTriageStyles as triageStyles } from './pr-ai-triage-styles'
import { AgentLaunchNotice } from '../AgentLaunchNotice'
import type { MobileAgentLaunchAvailability } from '../../session/mobile-agent-launch-availability'
// Launches the "Resolve conflicts with AI" agent. Absent for display-only usages.
export type PrConflictsTriage = {
resolveConflicts: () => void
isBusy: boolean
availability: MobileAgentLaunchAvailability
success: string | null
error: string | null
warning: string | null
undeliveredPrompt: string | null
}
type Props = {
// Accept conflict view-model inputs only; full PRInfo remains compatible.
pr: Pick<PRInfo, 'mergeable' | 'conflictSummary'>
// Explain absent file details as loading while refresh is in flight, matching desktop.
isRefreshing?: boolean
triage?: PrConflictsTriage
}
// Show hosted conflicts as paths or missing-list fallback, matching desktop conflict/notice components.
export function PRConflictingFilesSection({ pr, isRefreshing = false, triage }: Props) {
useMobileColorScheme()
const t = useMobileTranslation()
// Use clipboard seam: iOS custom scheme lacks page secure context, while Android https provides one.
const clipboard = useClipboardWriter()
// Track idle/success/failure: dropping pasteboard refusal used to look like successful copy, unlike Tasks error reporting.
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle')
const copiedResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const conflict = resolveConflictDisplay(pr)
useEffect(() => {
return () => {
if (copiedResetTimerRef.current) {
clearTimeout(copiedResetTimerRef.current)
}
}
}, [])
if (!conflict) {
return null
}
let noticeBody = t(
'pullRequest.conflictingFilesSection.detailsUnavailable',
'Conflict file details are unavailable'
)
if (isRefreshing) {
noticeBody = t('pullRequest.conflictingFilesSection.refreshing', 'Refreshing conflict details…')
} else if (conflict.localMergeClean) {
noticeBody = t(
'pullRequest.conflictingFilesSection.localMergeClean',
'GitHub reports conflicts, but local Git did not reproduce them. Refresh the PR or push the branch to recalculate mergeability.'
)
}
const copyRefreshCommands = async () => {
if (!conflict.mergeabilityRefreshCommands) {
return
}
let next: 'copied' | 'failed' = 'copied'
try {
await clipboard.writeText(conflict.mergeabilityRefreshCommands)
} catch {
next = 'failed'
}
if (copiedResetTimerRef.current) {
clearTimeout(copiedResetTimerRef.current)
}
setCopyState(next)
copiedResetTimerRef.current = setTimeout(() => {
copiedResetTimerRef.current = null
setCopyState('idle')
}, 1500)
}
const copyLabel =
copyState === 'copied'
? t('common.copied', 'Copied')
: copyState === 'failed'
? t('pullRequest.conflictingFilesSection.copyFailed', 'Failed to copy text')
: t('pullRequest.conflictingFilesSection.copyCommands', 'Copy commands')
const count = conflict.commitsBehind
// Preserve the commit's styling when a translation moves its placeholder.
const behindParts =
count !== null && conflict.baseCommit !== null
? (count === 1
? t(
'pullRequest.conflictingFilesSection.commitsBehind_one',
'{{count}} commit behind (base commit: {{baseCommit}})',
{ count, baseCommit: '\uFFFC' }
)
: t(
'pullRequest.conflictingFilesSection.commitsBehind_other',
'{{count}} commits behind (base commit: {{baseCommit}})',
{ count, baseCommit: '\uFFFC' }
)
).split('\uFFFC')
: null
return (
<PRSection title={t('pullRequest.conflictingFilesSection.title', 'Conflicts')}>
{behindParts ? (
<Text style={styles.meta}>
{behindParts[0]}
<Text style={styles.metaMono}>{conflict.baseCommit}</Text>
{behindParts.slice(1).join('')}
</Text>
) : null}
{conflict.fileDetailsUnavailable ? (
<View>
<Text style={styles.noticeTitle}>
{t(
'pullRequest.conflictingFilesSection.mustResolve',
'This branch has conflicts that must be resolved'
)}
</Text>
<Text style={styles.noticeBody}>{noticeBody}</Text>
{conflict.mergeabilityRefreshCommands ? (
<View style={styles.commandBox}>
<View style={styles.commandHeader}>
<Text style={styles.commandLabel}>
{t(
'pullRequest.conflictingFilesSection.runFromWorktree',
'Run from this worktree'
)}
</Text>
<Pressable
style={({ pressed }) => [
styles.copyCommandButton,
pressed && styles.copyCommandButtonPressed
]}
onPress={() => void copyRefreshCommands()}
accessibilityRole="button"
accessibilityLabel={t(
'pullRequest.conflictingFilesSection.copyRefreshCommands',
'Copy mergeability refresh commands'
)}
>
{copyState === 'copied' ? (
<Check size={13} color={colors.textPrimary} strokeWidth={2.2} />
) : (
<Copy size={13} color={colors.textPrimary} strokeWidth={2.2} />
)}
<Text style={styles.copyCommandText}>{copyLabel}</Text>
</Pressable>
</View>
<Text selectable style={styles.commandText}>
{conflict.mergeabilityRefreshCommands}
</Text>
</View>
) : null}
</View>
) : (
<View>
<View style={styles.filesHeader}>
<FileWarning size={14} color={colors.textSecondary} strokeWidth={2} />
<Text style={styles.filesHeaderText}>
{t('pullRequest.conflictingFilesSection.filesHeading', 'Conflicting files')}
</Text>
</View>
<ScrollView
style={styles.fileList}
contentContainerStyle={styles.fileListContent}
nestedScrollEnabled
showsVerticalScrollIndicator={false}
>
{conflict.files.map((filePath) => (
<View key={filePath} style={styles.fileRow}>
<Text style={styles.filePath}>{filePath}</Text>
</View>
))}
</ScrollView>
</View>
)}
{/* Launch desktop-style conflict agent to bring base branch in and complete merge. */}
{triage ? (
<View style={triageStyles.triageArea}>
<Pressable
style={({ pressed }) => [
triageStyles.triageButton,
pressed && triageStyles.triageButtonPressed
]}
onPress={triage.resolveConflicts}
disabled={triage.isBusy || triage.availability !== 'available'}
accessibilityRole="button"
accessibilityLabel={t(
'pullRequest.conflictingFilesSection.resolveWithAi',
'Resolve conflicts with AI'
)}
>
{triage.isBusy ? (
<ActivityIndicator color={colors.textSecondary} />
) : (
<Sparkles size={14} color={colors.textSecondary} strokeWidth={2.2} />
)}
<Text style={triageStyles.triageButtonText}>
{t('pullRequest.conflictingFilesSection.resolveWithAi', 'Resolve conflicts with AI')}
</Text>
</Pressable>
<AgentLaunchNotice
availability={triage.availability}
success={triage.success}
error={triage.error}
warning={triage.warning}
undeliveredPrompt={triage.undeliveredPrompt}
errorStyle={triageStyles.triageError}
/>
</View>
) : null}
</PRSection>
)
}
@@ -1,98 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { PRInfo } from '../../../../src/shared/github/pull-request-types'
import {
buildMergeabilityRefreshCommands,
resolveConflictDisplay
} from './pr-conflict-presentation'
function pr(over: Partial<PRInfo>): PRInfo {
return {
number: 1,
title: 't',
state: 'open',
url: '',
checksStatus: 'success',
updatedAt: '',
mergeable: 'MERGEABLE',
...over
}
}
describe('resolveConflictDisplay', () => {
it('builds safe mergeability refresh commands', () => {
expect(buildMergeabilityRefreshCommands()).toBe(
[
'git fetch origin',
'git commit --allow-empty --only -m "chore: refresh PR mergeability"',
'git push'
].join('\n')
)
})
it('returns null when there are no conflicts', () => {
expect(resolveConflictDisplay(pr({ mergeable: 'MERGEABLE' }))).toBeNull()
expect(resolveConflictDisplay(pr({ mergeable: 'UNKNOWN' }))).toBeNull()
})
it('lists conflicting files with commit metadata', () => {
const display = resolveConflictDisplay(
pr({
mergeable: 'CONFLICTING',
conflictSummary: {
baseRef: 'main',
baseCommit: 'abc1234',
commitsBehind: 3,
files: ['src/a.ts', 'src/b.ts']
}
})
)
expect(display).toEqual({
files: ['src/a.ts', 'src/b.ts'],
commitsBehind: 3,
baseCommit: 'abc1234',
fileDetailsUnavailable: false,
localMergeClean: false,
mergeabilityRefreshCommands: null
})
})
it('flags file-details-unavailable when conflicting but no file list', () => {
const display = resolveConflictDisplay(pr({ mergeable: 'CONFLICTING' }))
expect(display).toEqual({
files: [],
commitsBehind: null,
baseCommit: null,
fileDetailsUnavailable: true,
localMergeClean: false,
mergeabilityRefreshCommands: null
})
})
it('flags unavailable when conflictSummary has an empty file list', () => {
const display = resolveConflictDisplay(
pr({
mergeable: 'CONFLICTING',
conflictSummary: { baseRef: 'main', baseCommit: 'x', commitsBehind: 0, files: [] }
})
)
expect(display?.fileDetailsUnavailable).toBe(true)
})
it('flags locally clean when GitHub reports a conflict that local git does not reproduce', () => {
const display = resolveConflictDisplay(
pr({
mergeable: 'CONFLICTING',
conflictSummary: {
baseRef: 'main',
baseCommit: 'x',
commitsBehind: 1,
files: [],
localMergeState: 'clean'
}
})
)
expect(display?.localMergeClean).toBe(true)
expect(display?.mergeabilityRefreshCommands).toContain('git fetch origin')
expect(display?.mergeabilityRefreshCommands).toContain('git commit --allow-empty --only')
})
})
@@ -1,47 +0,0 @@
import type { PRInfo } from '../../../../src/shared/github/pull-request-types'
// Keep conflict view-model pure and RN-free; follow desktop ConflictingFilesSection/MergeConflictNotice logic.
export type ConflictDisplay = {
// Host may report conflicts before file list becomes available.
files: string[]
commitsBehind: number | null
baseCommit: string | null
// Missing-list conflicts need desktop-style fallback notice.
fileDetailsUnavailable: boolean
// True when the host reports conflicts but the local merge simulation is clean.
localMergeClean: boolean
mergeabilityRefreshCommands: string | null
}
// Render only hosted CONFLICTING; MERGEABLE/UNKNOWN show no section, matching desktop.
export function hasMergeConflicts(pr: Pick<PRInfo, 'mergeable'>): boolean {
return pr.mergeable === 'CONFLICTING'
}
export function buildMergeabilityRefreshCommands(): string {
return [
'git fetch origin',
'git commit --allow-empty --only -m "chore: refresh PR mergeability"',
'git push'
].join('\n')
}
// Return conflict model for both listed and missing-file cases so fallback still renders; null when no hosted conflict.
export function resolveConflictDisplay(
pr: Pick<PRInfo, 'mergeable' | 'conflictSummary'>
): ConflictDisplay | null {
if (!hasMergeConflicts(pr)) {
return null
}
const files = pr.conflictSummary?.files ?? []
const localMergeClean = pr.conflictSummary?.localMergeState === 'clean'
return {
files,
commitsBehind: pr.conflictSummary?.commitsBehind ?? null,
baseCommit: pr.conflictSummary?.baseCommit ?? null,
fileDetailsUnavailable: files.length === 0,
localMergeClean,
mergeabilityRefreshCommands: localMergeClean ? buildMergeabilityRefreshCommands() : null
}
}
@@ -1,101 +0,0 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
import { themedStyleSheet } from '../../theme/themed-styles'
// Match desktop conflict section’s muted layout without growing shared sidebar styles.
export const prConflictStyles = themedStyleSheet(() => ({
meta: {
color: colors.textSecondary,
fontSize: 11
},
metaMono: {
fontFamily: typography.monoFamily,
color: colors.textSecondary,
fontSize: 11
},
filesHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginTop: spacing.sm
},
filesHeaderText: {
color: colors.textSecondary,
fontSize: 11
},
// Cap/scroll conflict paths within outer sidebar so large set cannot displace other sections.
fileList: {
maxHeight: 180,
marginTop: spacing.sm
},
fileListContent: {
gap: spacing.xs
},
fileRow: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgRaised,
borderRadius: radii.surface,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs
},
filePath: {
color: colors.textPrimary,
fontSize: 11,
fontFamily: typography.monoFamily
},
noticeTitle: {
color: colors.textPrimary,
fontSize: 11,
fontWeight: '600'
},
noticeBody: {
color: colors.textSecondary,
fontSize: 11,
marginTop: spacing.xs
},
commandBox: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgRaised,
borderRadius: radii.surface,
marginTop: spacing.sm,
padding: spacing.sm
},
commandHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm
},
commandLabel: {
color: colors.textSecondary,
fontSize: 10,
fontWeight: '600'
},
copyCommandButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
borderRadius: radii.control,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs
},
copyCommandButtonPressed: {
backgroundColor: colors.borderSubtle
},
copyCommandText: {
color: colors.textPrimary,
fontSize: 11,
fontWeight: '600'
},
commandText: {
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: 10,
lineHeight: 15,
marginTop: spacing.sm
}
}))
-4
View File
@@ -181,10 +181,6 @@
"yearsAgo_one": "{{count}}y ago",
"yearsAgo_other": "{{count}}y ago"
},
"conflictingFilesSection": {
"commitsBehind_one": "{{count}} commit behind (base commit: {{baseCommit}})",
"commitsBehind_other": "{{count}} commits behind (base commit: {{baseCommit}})"
},
"emptyState": {
"commitsReady_one": "{{count}} commit on {{branch}} is ready to go into {{base}}.",
"commitsReady_other": "{{count}} commits on {{branch}} are ready to go into {{base}}."
-15
View File
@@ -1525,21 +1525,6 @@
"showMore": "{{count}} weitere anzeigen",
"title": "Kommentare"
},
"conflictingFilesSection": {
"commitsBehind_one": "{{count}} Commit im Rückstand (Basis-Commit: {{baseCommit}})",
"commitsBehind_other": "{{count}} Commits im Rückstand (Basis-Commit: {{baseCommit}})",
"copyCommands": "Befehle kopieren",
"copyFailed": "Text konnte nicht kopiert werden",
"copyRefreshCommands": "Befehle zum Aktualisieren der Merge-Fähigkeit kopieren",
"detailsUnavailable": "Details zu Dateien mit Konflikten sind nicht verfügbar",
"filesHeading": "Dateien mit Konflikten",
"localMergeClean": "GitHub meldet Konflikte, die mit lokalem Git nicht reproduziert werden konnten. PR aktualisieren oder Branch pushen, um die Merge-Fähigkeit neu zu berechnen.",
"mustResolve": "Dieser Branch enthält Konflikte, die gelöst werden müssen",
"refreshing": "Konfliktdetails werden aktualisiert…",
"resolveWithAi": "Konflikte mit KI lösen",
"runFromWorktree": "Aus diesem Worktree ausführen",
"title": "Konflikte"
},
"linkPrForm": {
"linkButton": "Pull Request verknüpfen",
"linkNumber": "#{{number}} verknüpfen",
-15
View File
@@ -1414,21 +1414,6 @@
"resolvedCommentCount": "Resolved comment by {{author}} ({{count}})",
"commentsLoadFailedRetry": "Could not load comments."
},
"conflictingFilesSection": {
"detailsUnavailable": "Conflict file details are unavailable",
"refreshing": "Refreshing conflict details…",
"localMergeClean": "GitHub reports conflicts, but local Git did not reproduce them. Refresh the PR or push the branch to recalculate mergeability.",
"copyFailed": "Failed to copy text",
"copyCommands": "Copy commands",
"commitsBehind_one": "{{count}} commit behind (base commit: {{baseCommit}})",
"commitsBehind_other": "{{count}} commits behind (base commit: {{baseCommit}})",
"title": "Conflicts",
"mustResolve": "This branch has conflicts that must be resolved",
"runFromWorktree": "Run from this worktree",
"copyRefreshCommands": "Copy mergeability refresh commands",
"filesHeading": "Conflicting files",
"resolveWithAi": "Resolve conflicts with AI"
},
"reviewersSection": {
"requested": "Requested",
"approved": "Approved",
-16
View File
@@ -1568,22 +1568,6 @@
"showMore": "Mostrar {{count}} más",
"title": "Comentarios"
},
"conflictingFilesSection": {
"commitsBehind_many": "{{count}} commits por detrás (commit base: {{baseCommit}})",
"commitsBehind_one": "{{count}} commit por detrás (commit base: {{baseCommit}})",
"commitsBehind_other": "{{count}} commits por detrás (commit base: {{baseCommit}})",
"copyCommands": "Copiar comandos",
"copyFailed": "No se pudo copiar el texto",
"copyRefreshCommands": "Copiar comandos para actualizar la capacidad de merge",
"detailsUnavailable": "Los detalles del archivo en conflicto no están disponibles",
"filesHeading": "Archivos con conflictos",
"localMergeClean": "GitHub indica que hay conflictos, pero Git no los ha reproducido en local. Actualiza la PR o haz push de la rama para recalcular la capacidad de merge.",
"mustResolve": "Esta rama tiene conflictos que deben resolverse",
"refreshing": "Actualizando detalles del conflicto…",
"resolveWithAi": "Resolver conflictos con IA",
"runFromWorktree": "Ejecutar desde este worktree",
"title": "Conflictos"
},
"linkPrForm": {
"linkButton": "Vincular pull request",
"linkNumber": "Vincular #{{number}}",
-16
View File
@@ -1568,22 +1568,6 @@
"showMore": "Afficher {{count}} de plus",
"title": "Commentaires"
},
"conflictingFilesSection": {
"commitsBehind_many": "{{count}} commits de retard (commit de base : {{baseCommit}})",
"commitsBehind_one": "{{count}} commit de retard (commit de base : {{baseCommit}})",
"commitsBehind_other": "{{count}} commits de retard (commit de base : {{baseCommit}})",
"copyCommands": "Copier les commandes",
"copyFailed": "Impossible de copier le texte",
"copyRefreshCommands": "Copier les commandes de recalcul du merge",
"detailsUnavailable": "Les détails du fichier en conflit sont indisponibles",
"filesHeading": "Fichiers en conflit",
"localMergeClean": "GitHub signale des conflits, mais Git ne les a pas reproduits en local. Actualisez la PR ou poussez la branche pour réévaluer la possibilité de fusion.",
"mustResolve": "Cette branche contient des conflits qui doivent être résolus",
"refreshing": "Actualisation des détails de conflit…",
"resolveWithAi": "Résoudre les conflits avec l'IA",
"runFromWorktree": "Exécuter depuis ce worktree",
"title": "Conflits"
},
"linkPrForm": {
"linkButton": "Lier la pull request",
"linkNumber": "Lier #{{number}}",
-15
View File
@@ -1521,21 +1521,6 @@
"showMore": "さらに {{count}} 件を表示",
"title": "コメント"
},
"conflictingFilesSection": {
"commitsBehind_one": "{{count}} コミット遅れ(ベースコミット: {{baseCommit}})",
"commitsBehind_other": "{{count}} コミット遅れ(ベースコミット: {{baseCommit}})",
"copyCommands": "コマンドをコピー",
"copyFailed": "テキストのコピーに失敗しました",
"copyRefreshCommands": "マージ可否を再取得するコマンドをコピー",
"detailsUnavailable": "競合ファイルの詳細は利用できません",
"filesHeading": "競合するファイル",
"localMergeClean": "GitHub では競合が報告されていますが、ローカルの Git では再現されませんでした。PR を更新するか、ブランチをプッシュしてマージ可否を再計算してください。",
"mustResolve": "このブランチには解決する必要のある競合があります",
"refreshing": "競合の詳細を更新しています…",
"resolveWithAi": "AI で競合を解決",
"runFromWorktree": "このワークツリーから実行",
"title": "競合"
},
"linkPrForm": {
"linkButton": "PR をリンク",
"linkNumber": "#{{number}} をリンク",
-15
View File
@@ -1521,21 +1521,6 @@
"showMore": "{{count}}개 더 보기",
"title": "댓글"
},
"conflictingFilesSection": {
"commitsBehind_one": "커밋 {{count}}개 뒤처짐 (기준 커밋: {{baseCommit}})",
"commitsBehind_other": "커밋 {{count}}개 뒤처짐 (기준 커밋: {{baseCommit}})",
"copyCommands": "명령 복사",
"copyFailed": "텍스트 복사 실패",
"copyRefreshCommands": "병합 가능성 새로 고침 명령 복사",
"detailsUnavailable": "충돌 파일 세부정보를 사용할 수 없습니다.",
"filesHeading": "충돌하는 파일",
"localMergeClean": "GitHub에서는 충돌이 있다고 보고하지만 로컬 Git에서는 충돌이 재현되지 않았습니다. PR을 새로고침하거나 브랜치를 푸시해 병합 가능성을 다시 계산하세요.",
"mustResolve": "이 브랜치에는 해결해야 할 충돌이 있습니다.",
"refreshing": "충돌 세부정보 새로고침 중…",
"resolveWithAi": "AI로 충돌 해결",
"runFromWorktree": "이 워크트리에서 실행",
"title": "충돌"
},
"linkPrForm": {
"linkButton": "PR 연결",
"linkNumber": "#{{number}} 연결",
-16
View File
@@ -1568,22 +1568,6 @@
"showMore": "Mostrar mais {{count}}",
"title": "Comentários"
},
"conflictingFilesSection": {
"commitsBehind_many": "{{count}} commits atrás (commit base: {{baseCommit}})",
"commitsBehind_one": "{{count}} commit atrás (commit base: {{baseCommit}})",
"commitsBehind_other": "{{count}} commits atrás (commit base: {{baseCommit}})",
"copyCommands": "Copiar comandos",
"copyFailed": "Falha ao copiar o texto",
"copyRefreshCommands": "Copiar comandos de atualização de possibilidade de merge",
"detailsUnavailable": "Os detalhes dos arquivos em conflito não estão disponíveis",
"filesHeading": "Arquivos em conflito",
"localMergeClean": "O GitHub informa conflitos, mas o Git local não os reproduziu. Atualize o PR ou faça push da branch para recalcular a possibilidade de merge.",
"mustResolve": "Esta branch tem conflitos que precisam ser resolvidos",
"refreshing": "Atualizando os detalhes do conflito…",
"resolveWithAi": "Resolver conflitos com IA",
"runFromWorktree": "Executar a partir desta worktree",
"title": "Conflitos"
},
"linkPrForm": {
"linkButton": "Vincular pull request",
"linkNumber": "Vincular #{{number}}",
-17
View File
@@ -1611,23 +1611,6 @@
"showMore": "Показать ещё {{count}}",
"title": "Комментарии"
},
"conflictingFilesSection": {
"commitsBehind_few": "Отставание на {{count}} коммита (базовый коммит: {{baseCommit}})",
"commitsBehind_many": "Отставание на {{count}} коммитов (базовый коммит: {{baseCommit}})",
"commitsBehind_one": "Отставание на {{count}} коммит (базовый коммит: {{baseCommit}})",
"commitsBehind_other": "Отставание на {{count}} коммита (базовый коммит: {{baseCommit}})",
"copyCommands": "Скопировать команды",
"copyFailed": "Не удалось скопировать текст",
"copyRefreshCommands": "Скопировать команды для повторной проверки возможности слияния",
"detailsUnavailable": "Сведения о конфликтующих файлах недоступны",
"filesHeading": "Конфликтующие файлы",
"localMergeClean": "GitHub сообщает о конфликтах, но локальный Git их не воспроизвёл. Обновите PR или отправьте ветку, чтобы повторно проверить возможность слияния.",
"mustResolve": "В этой ветке есть конфликты, которые нужно разрешить",
"refreshing": "Обновление сведений о конфликтах…",
"resolveWithAi": "Разрешить конфликты с помощью ИИ",
"runFromWorktree": "Выполните в этом рабочем дереве",
"title": "Конфликты"
},
"linkPrForm": {
"linkButton": "Привязать пул-реквест",
"linkNumber": "Привязать #{{number}}",
-15
View File
@@ -1525,21 +1525,6 @@
"showMore": "{{count}} tane daha göster",
"title": "Yorumlar"
},
"conflictingFilesSection": {
"commitsBehind_one": "{{count}} commit geride (temel commit: {{baseCommit}})",
"commitsBehind_other": "{{count}} commit geride (temel commit: {{baseCommit}})",
"copyCommands": "Komutları kopyala",
"copyFailed": "Metin kopyalanamadı",
"copyRefreshCommands": "Merge edilebilirlik yenileme komutlarını kopyala",
"detailsUnavailable": "Conflict'li dosyaların ayrıntıları kullanılamıyor",
"filesHeading": "Conflict'li dosyalar",
"localMergeClean": "GitHub conflict bildiriyor ama yerel Git bunları yeniden oluşturamadı. Merge edilebilirliği yeniden hesaplamak için PR görünümünü yenileyin veya branch'i push edin.",
"mustResolve": "Bu branch'in çözülmesi gereken conflict'leri var",
"refreshing": "Conflict ayrıntıları yenileniyor…",
"resolveWithAi": "Conflict'leri AI ile çöz",
"runFromWorktree": "Bu worktree'den çalıştır",
"title": "Conflict'ler"
},
"linkPrForm": {
"linkButton": "Pull request'i bağla",
"linkNumber": "Bağla: #{{number}}",
-15
View File
@@ -1521,21 +1521,6 @@
"showMore": "再顯示 {{count}} 則",
"title": "留言"
},
"conflictingFilesSection": {
"commitsBehind_one": "落後 {{count}} 個提交(基底提交:{{baseCommit}})",
"commitsBehind_other": "落後 {{count}} 個提交(基底提交:{{baseCommit}})",
"copyCommands": "複製命令",
"copyFailed": "無法複製文字",
"copyRefreshCommands": "複製重新判斷是否可合併的命令",
"detailsUnavailable": "無法取得衝突檔案的詳細資訊",
"filesHeading": "衝突的檔案",
"localMergeClean": "GitHub 回報有衝突,但本機 Git 未重現這些衝突。請重新整理 PR 或推送分支,以重新判斷是否可合併。",
"mustResolve": "此分支有必須解決的衝突",
"refreshing": "正在重新整理衝突詳細資訊…",
"resolveWithAi": "使用 AI 解決衝突",
"runFromWorktree": "從此工作樹執行",
"title": "衝突"
},
"linkPrForm": {
"linkButton": "連結 PR",
"linkNumber": "連結 #{{number}}",
-15
View File
@@ -1521,21 +1521,6 @@
"showMore": "再显示 {{count}} 条",
"title": "评论"
},
"conflictingFilesSection": {
"commitsBehind_one": "落后 {{count}} 个提交(基础提交:{{baseCommit}})",
"commitsBehind_other": "落后 {{count}} 个提交(基础提交:{{baseCommit}})",
"copyCommands": "复制命令",
"copyFailed": "复制文本失败",
"copyRefreshCommands": "复制可合并性刷新命令",
"detailsUnavailable": "冲突文件详细信息不可用",
"filesHeading": "冲突的文件",
"localMergeClean": "GitHub 提示存在冲突,但本地 Git 未能复现。请刷新 PR 或推送分支,以重新计算可合并性。",
"mustResolve": "该分支存在必须解决的冲突",
"refreshing": "刷新冲突细节…",
"resolveWithAi": "使用 AI 解决冲突",
"runFromWorktree": "从此工作树运行",
"title": "冲突"
},
"linkPrForm": {
"linkButton": "关联 PR",
"linkNumber": "关联 #{{number}}",
@@ -362,8 +362,6 @@ export function MobileSourceControlPanel({
bottomInset={barInset}
onOpenChanges={() => selectTab('changes')}
branchCompare={branchCompare}
// The bar offers Create and the review steps; on a local step Merge stays reachable here.
showBottomAction={!nextStep.offersMerge && prTab.state.kind === 'ready'}
/>
</View>
) : activeTab === 'pr' ? (
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import type { SourceControlNextStep } from '../../../src/shared/source-control-next-step-decision'
import {
buildMobileNextStepBar,
type BuildMobileNextStepBarArgs
} from './mobile-source-control-next-step'
const noop = () => {}
function bar(step: SourceControlNextStep) {
const args: BuildMobileNextStepBarArgs = {
step,
primaryAction: {
kind: 'commit',
label: 'Commit',
accessibilityLabel: 'Commit',
accessibilityHint: '',
disabled: false,
loading: false,
onPress: noop
},
createPrAction: {
visible: false,
label: 'Create pull request',
reviewLabel: 'pull request',
disabled: false,
loading: false,
pushFirst: false,
onPress: noop
},
pr: null,
prDetails: null,
createBlocked: false,
agentName: 'Claude',
busy: {
completingMerge: false,
agentLaunch: false,
agentUnavailable: false,
merge: false,
autoMerge: false,
ioBusy: false,
prMutationsBlocked: false
},
handlers: {
completeMerge: noop,
resolveConflicts: noop,
fixChecks: noop,
merge: noop,
setAutoMerge: noop
}
}
return buildMobileNextStepBar(args)
}
describe('the next-step bar reason line', () => {
// The PR tab's gate row already says "Conflicts with main"; the button names the fix, so a reason would repeat it.
it.each(['local', 'review'] as const)('leaves Resolve with the agent bare (%s)', (scope) => {
const view = bar({ kind: 'resolve_conflicts', scope })
expect(view).toMatchObject({ kind: 'button', label: 'Resolve with Claude', reason: null })
})
it('keeps the reason beside Fix, whose label does not say what failed', () => {
const view = bar({ kind: 'fix_checks', failedChecks: 2 })
expect(view).toMatchObject({ kind: 'button', label: 'Fix with Claude' })
expect(view.kind === 'button' ? view.reason?.text : null).toBe('2 checks failing')
})
})
@@ -18,7 +18,6 @@ import { getBrokenChecks } from '../../../src/shared/pr-checks-fix-prompt'
import { resolveSourceControlCommitAreaPrimaryActionDecision } from '../../../src/shared/source-control-primary-action-decision'
import {
resolveSourceControlNextStep,
type SourceControlNextStep,
type SourceControlNextStepReview
} from '../../../src/shared/source-control-next-step-decision'
import { splitWorktreeIdForFilesystem } from '../../../src/shared/worktree/id'
@@ -48,16 +47,6 @@ type Params = {
onMerge?: () => void
}
// Steps where the bar itself offers the PR's merge actions.
// Agent steps leave Merge and auto-merge to the PR body.
const REVIEW_STEPS: ReadonlySet<SourceControlNextStep['kind']> = new Set([
'merge_when_ready',
'auto_merge_on',
'merge',
'merge_blocked',
'merged'
])
export type MobileMergeConfirm = {
title: string
message: string
@@ -288,9 +277,7 @@ export function useMobileSourceControlNextStep({
}
: null
// While the bar is on a local step, the PR body keeps its own Merge so it stays reachable.
return {
offersMerge: REVIEW_STEPS.has(step.kind),
view,
notice,
agentNotice,