From 56e83655b00a0d98d7185091c3006ce2bb44d31c Mon Sep 17 00:00:00 2001
From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com>
Date: Sun, 1 Nov 2026 23:36:51 +0000
Subject: [PATCH] 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.
---
mobile/src/components/MobilePRSidebar.tsx | 33 +--
.../PRConflictingFilesSection.test.ts | 101 --------
.../pr-sidebar/PRConflictingFilesSection.tsx | 228 ------------------
.../pr-conflict-presentation.test.ts | 98 --------
.../pr-sidebar/pr-conflict-presentation.ts | 47 ----
.../pr-sidebar/pr-conflict-styles.ts | 101 --------
mobile/src/i18n/en-runtime-required.json | 4 -
mobile/src/i18n/locales/de.json | 15 --
mobile/src/i18n/locales/en.json | 15 --
mobile/src/i18n/locales/es.json | 16 --
mobile/src/i18n/locales/fr.json | 16 --
mobile/src/i18n/locales/ja.json | 15 --
mobile/src/i18n/locales/ko.json | 15 --
mobile/src/i18n/locales/pt.json | 16 --
mobile/src/i18n/locales/ru.json | 17 --
mobile/src/i18n/locales/tr.json | 15 --
mobile/src/i18n/locales/zh-Hant.json | 15 --
mobile/src/i18n/locales/zh.json | 15 --
.../MobileSourceControlPanel.tsx | 2 -
.../mobile-source-control-next-step.test.ts | 67 +++++
.../use-mobile-source-control-next-step.ts | 13 -
21 files changed, 77 insertions(+), 787 deletions(-)
delete mode 100644 mobile/src/components/pr-sidebar/PRConflictingFilesSection.test.ts
delete mode 100644 mobile/src/components/pr-sidebar/PRConflictingFilesSection.tsx
delete mode 100644 mobile/src/components/pr-sidebar/pr-conflict-presentation.test.ts
delete mode 100644 mobile/src/components/pr-sidebar/pr-conflict-presentation.ts
delete mode 100644 mobile/src/components/pr-sidebar/pr-conflict-styles.ts
create mode 100644 mobile/src/source-control/mobile-source-control-next-step.test.ts
diff --git a/mobile/src/components/MobilePRSidebar.tsx b/mobile/src/components/MobilePRSidebar.tsx
index 23b47bbe5d7..39958c8e696 100644
--- a/mobile/src/components/MobilePRSidebar.tsx
+++ b/mobile/src/components/MobilePRSidebar.tsx
@@ -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({
)
}
- const footer = showBottomAction ? : null
if (branch === 'none') {
return (
@@ -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({
)
})}
-
>
@@ -359,7 +341,12 @@ export function MobilePRSidebar({ onRetry, bottomInset = 0, ...input }: SidebarP
onClose={() => setMenuOpen(false)}
/>
-
+ }
+ />
>
)}
diff --git a/mobile/src/components/pr-sidebar/PRConflictingFilesSection.test.ts b/mobile/src/components/pr-sidebar/PRConflictingFilesSection.test.ts
deleted file mode 100644
index 44763f74efa..00000000000
--- a/mobile/src/components/pr-sidebar/PRConflictingFilesSection.test.ts
+++ /dev/null
@@ -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 = {
- mergeable: 'CONFLICTING',
- conflictSummary: {
- files: [],
- localMergeState: 'clean',
- commitsBehind: 2,
- baseCommit: 'abc1234',
- baseRef: 'main'
- }
-}
-
-const COMMANDS = buildMergeabilityRefreshCommands()
-
-function press(tree: ReactTestRenderer): Promise {
- 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 {
- 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')
- })
-})
diff --git a/mobile/src/components/pr-sidebar/PRConflictingFilesSection.tsx b/mobile/src/components/pr-sidebar/PRConflictingFilesSection.tsx
deleted file mode 100644
index c0f84985705..00000000000
--- a/mobile/src/components/pr-sidebar/PRConflictingFilesSection.tsx
+++ /dev/null
@@ -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
- // 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 | 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 (
-
- {behindParts ? (
-
- {behindParts[0]}
- {conflict.baseCommit}
- {behindParts.slice(1).join('')}
-
- ) : null}
-
- {conflict.fileDetailsUnavailable ? (
-
-
- {t(
- 'pullRequest.conflictingFilesSection.mustResolve',
- 'This branch has conflicts that must be resolved'
- )}
-
- {noticeBody}
- {conflict.mergeabilityRefreshCommands ? (
-
-
-
- {t(
- 'pullRequest.conflictingFilesSection.runFromWorktree',
- 'Run from this worktree'
- )}
-
- [
- styles.copyCommandButton,
- pressed && styles.copyCommandButtonPressed
- ]}
- onPress={() => void copyRefreshCommands()}
- accessibilityRole="button"
- accessibilityLabel={t(
- 'pullRequest.conflictingFilesSection.copyRefreshCommands',
- 'Copy mergeability refresh commands'
- )}
- >
- {copyState === 'copied' ? (
-
- ) : (
-
- )}
- {copyLabel}
-
-
-
- {conflict.mergeabilityRefreshCommands}
-
-
- ) : null}
-
- ) : (
-
-
-
-
- {t('pullRequest.conflictingFilesSection.filesHeading', 'Conflicting files')}
-
-
-
- {conflict.files.map((filePath) => (
-
- {filePath}
-
- ))}
-
-
- )}
-
- {/* Launch desktop-style conflict agent to bring base branch in and complete merge. */}
- {triage ? (
-
- [
- 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 ? (
-
- ) : (
-
- )}
-
- {t('pullRequest.conflictingFilesSection.resolveWithAi', 'Resolve conflicts with AI')}
-
-
-
-
- ) : null}
-
- )
-}
diff --git a/mobile/src/components/pr-sidebar/pr-conflict-presentation.test.ts b/mobile/src/components/pr-sidebar/pr-conflict-presentation.test.ts
deleted file mode 100644
index 4c898a58bfe..00000000000
--- a/mobile/src/components/pr-sidebar/pr-conflict-presentation.test.ts
+++ /dev/null
@@ -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 {
- 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')
- })
-})
diff --git a/mobile/src/components/pr-sidebar/pr-conflict-presentation.ts b/mobile/src/components/pr-sidebar/pr-conflict-presentation.ts
deleted file mode 100644
index 8f6b1082a64..00000000000
--- a/mobile/src/components/pr-sidebar/pr-conflict-presentation.ts
+++ /dev/null
@@ -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): 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
-): 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
- }
-}
diff --git a/mobile/src/components/pr-sidebar/pr-conflict-styles.ts b/mobile/src/components/pr-sidebar/pr-conflict-styles.ts
deleted file mode 100644
index 6a6cf078ade..00000000000
--- a/mobile/src/components/pr-sidebar/pr-conflict-styles.ts
+++ /dev/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
- }
-}))
diff --git a/mobile/src/i18n/en-runtime-required.json b/mobile/src/i18n/en-runtime-required.json
index 82edfb8b3c1..fce4c01117f 100644
--- a/mobile/src/i18n/en-runtime-required.json
+++ b/mobile/src/i18n/en-runtime-required.json
@@ -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}}."
diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json
index c4438e3b46d..c88700e392e 100644
--- a/mobile/src/i18n/locales/de.json
+++ b/mobile/src/i18n/locales/de.json
@@ -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",
diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json
index 3ecd1df2797..81f764ae529 100644
--- a/mobile/src/i18n/locales/en.json
+++ b/mobile/src/i18n/locales/en.json
@@ -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",
diff --git a/mobile/src/i18n/locales/es.json b/mobile/src/i18n/locales/es.json
index e06f495a16b..1fe588178d1 100644
--- a/mobile/src/i18n/locales/es.json
+++ b/mobile/src/i18n/locales/es.json
@@ -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}}",
diff --git a/mobile/src/i18n/locales/fr.json b/mobile/src/i18n/locales/fr.json
index 40cb6184fef..f10df57ff4b 100644
--- a/mobile/src/i18n/locales/fr.json
+++ b/mobile/src/i18n/locales/fr.json
@@ -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}}",
diff --git a/mobile/src/i18n/locales/ja.json b/mobile/src/i18n/locales/ja.json
index 32b9b5941a2..c2257fa84cd 100644
--- a/mobile/src/i18n/locales/ja.json
+++ b/mobile/src/i18n/locales/ja.json
@@ -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}} をリンク",
diff --git a/mobile/src/i18n/locales/ko.json b/mobile/src/i18n/locales/ko.json
index bcca2fca555..e1873c4d4d1 100644
--- a/mobile/src/i18n/locales/ko.json
+++ b/mobile/src/i18n/locales/ko.json
@@ -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}} 연결",
diff --git a/mobile/src/i18n/locales/pt.json b/mobile/src/i18n/locales/pt.json
index 93ed011a2b8..40318a2e1c9 100644
--- a/mobile/src/i18n/locales/pt.json
+++ b/mobile/src/i18n/locales/pt.json
@@ -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}}",
diff --git a/mobile/src/i18n/locales/ru.json b/mobile/src/i18n/locales/ru.json
index 0ca130d1b95..9dfc2513af4 100644
--- a/mobile/src/i18n/locales/ru.json
+++ b/mobile/src/i18n/locales/ru.json
@@ -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}}",
diff --git a/mobile/src/i18n/locales/tr.json b/mobile/src/i18n/locales/tr.json
index 4706bed8d0f..35896fd7f97 100644
--- a/mobile/src/i18n/locales/tr.json
+++ b/mobile/src/i18n/locales/tr.json
@@ -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}}",
diff --git a/mobile/src/i18n/locales/zh-Hant.json b/mobile/src/i18n/locales/zh-Hant.json
index 2377832a35d..82695b97650 100644
--- a/mobile/src/i18n/locales/zh-Hant.json
+++ b/mobile/src/i18n/locales/zh-Hant.json
@@ -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}}",
diff --git a/mobile/src/i18n/locales/zh.json b/mobile/src/i18n/locales/zh.json
index 4c83f3760a4..b5238200f14 100644
--- a/mobile/src/i18n/locales/zh.json
+++ b/mobile/src/i18n/locales/zh.json
@@ -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}}",
diff --git a/mobile/src/source-control/MobileSourceControlPanel.tsx b/mobile/src/source-control/MobileSourceControlPanel.tsx
index 94e3c29ca93..0385302c637 100644
--- a/mobile/src/source-control/MobileSourceControlPanel.tsx
+++ b/mobile/src/source-control/MobileSourceControlPanel.tsx
@@ -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'}
/>
) : activeTab === 'pr' ? (
diff --git a/mobile/src/source-control/mobile-source-control-next-step.test.ts b/mobile/src/source-control/mobile-source-control-next-step.test.ts
new file mode 100644
index 00000000000..0cfb6f016f5
--- /dev/null
+++ b/mobile/src/source-control/mobile-source-control-next-step.test.ts
@@ -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')
+ })
+})
diff --git a/mobile/src/source-control/use-mobile-source-control-next-step.ts b/mobile/src/source-control/use-mobile-source-control-next-step.ts
index 5e772bec131..2d1f52fdaa5 100644
--- a/mobile/src/source-control/use-mobile-source-control-next-step.ts
+++ b/mobile/src/source-control/use-mobile-source-control-next-step.ts
@@ -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 = 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,