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,