import { useEffect, useState } from 'react' import { ActivityIndicator, Pressable, Text, View } from 'react-native' import { ArrowDown, ArrowDownUp, ArrowUp, Check, GitMerge, GitPullRequest, Sparkles, type LucideIcon } from 'lucide-react-native' import { colors, feedback, radii, spacing, typography } from '../theme/mobile-theme' import { themedStyleSheet } from '../theme/themed-styles' import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' import { useMobileTranslation } from '../i18n/use-mobile-translation' import { MessageBox } from '../components/composer-input/MessageBox' import { composerInputStyles } from '../components/composer-input/composer-input-styles' import { KEYBOARD_COMMIT_BAR_CLEARANCE } from './mobile-source-control-screen-state' import type { MobileSourceControlState } from './use-mobile-source-control-state' import type { MobileSourceControlPrimaryAction } from './mobile-source-control-primary-action' import { AgentLaunchNotice } from '../components/AgentLaunchNotice' import { ConfirmModal } from '../components/ConfirmModal' import type { MobileMergeConfirm } from './use-mobile-source-control-next-step' import type { MobileAgentLaunchAvailability } from '../session/agent-launch/mobile-agent-launch-availability' import type { PrAiTriageLaunchNotice } from './review/use-mobile-pr-ai-triage' import type { MobileNextStepBarView, MobileNextStepIcon, MobileNextStepTone } from './mobile-source-control-next-step' import { styles as sourceControlStyles } from './mobile-source-control-styles' const ICONS: Record = { push: ArrowUp, pull: ArrowDown, sync: ArrowDownUp, review: GitPullRequest, agent: Sparkles, merge: GitMerge, complete: Check } type ComposerState = Pick< MobileSourceControlState, | 'busyAction' | 'commitMessage' | 'setCommitMessage' | 'generatingMessage' | 'stagedCount' | 'generateCommitMessage' | 'cancelGenerateCommitMessage' > type Props = { view: MobileNextStepBarView composer: ComposerState primaryAction: MobileSourceControlPrimaryAction keyboardLift: number bottomInset: number /** A failed bar action, e.g. a push the remote rejected. */ notice: string | null /** How the last "Fix/Resolve with" launch went, with its prompt when it wasn't delivered. */ agentNotice: (PrAiTriageLaunchNotice & { availability: MobileAgentLaunchAvailability }) | null /** Set while Merge waits for the user to confirm. */ mergeConfirm: MobileMergeConfirm | null onHeightChange: (height: number) => void } /** The one next action for the branch, at the foot of both Source Control tabs. */ export function MobileSourceControlNextStepBar({ view, composer, primaryAction, keyboardLift, bottomInset, notice, agentNotice, mergeConfirm, onHeightChange }: Props) { useMobileColorScheme() const t = useMobileTranslation() const [composerFocused, setComposerFocused] = useState(false) // The bar rides the keyboard only for its own commit box; for any other field it steps aside. const lift = composerFocused && view.kind === 'composer' ? keyboardLift : 0 if ((view.kind === 'hidden' && !notice) || (keyboardLift > 0 && lift === 0)) { return null } const reason = view.kind === 'button' ? view.reason : null return ( onHeightChange(event.nativeEvent.layout.height)} style={[ styles.bar, { bottom: lift > 0 ? lift + KEYBOARD_COMMIT_BAR_CLEARANCE : 0, paddingBottom: lift > 0 ? spacing.md : spacing.md + bottomInset } ]} > {agentNotice ? : null} {notice ? ( {notice} ) : null} {reason ? ( {reason.lead ? {reason.lead} : null} {reason.text} ) : null} {view.kind === 'composer' ? ( ) : view.kind === 'button' ? ( ) : view.kind === 'auto_merge_on' ? ( {t('sourceControl.nextStep.willMergeWhenReady', 'Will merge when ready')} ) : view.kind === 'merged' ? ( {view.baseRef ? t('sourceControl.nextStep.mergedInto', 'Merged into {{base}}', { base: view.baseRef }) : t('sourceControl.nextStep.merged', 'Merged')} ) : null} mergeConfirm?.onConfirm()} onCancel={() => mergeConfirm?.onCancel()} /> ) } function BarButton({ label, Icon, tone, disabled, loading, accessibilityLabel, accessibilityHint, onPress }: { label: string Icon?: LucideIcon tone: MobileNextStepTone disabled: boolean loading: boolean accessibilityLabel?: string accessibilityHint?: string onPress: () => void }) { useMobileColorScheme() const off = disabled && !loading const fg = off ? colors.textMuted : tone === 'merge' ? colors.onMergeGreen : tone === 'secondary' ? colors.textPrimary : colors.bgBase return ( [ styles.button, tone === 'secondary' && styles.buttonSecondary, tone === 'merge' && styles.buttonMerge, off && styles.buttonOff, tone === 'secondary' && Icon === undefined && styles.buttonCompact, Icon !== undefined && styles.grow, pressed && styles.pressed ]} disabled={disabled || loading} onPress={onPress} accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? label} accessibilityHint={accessibilityHint} accessibilityState={{ disabled: disabled || loading, busy: loading }} > {loading ? ( ) : Icon ? ( ) : null} {label} ) } function CommitComposer({ composer, primaryAction, onFocusChange }: { composer: ComposerState primaryAction: MobileSourceControlPrimaryAction onFocusChange: (focused: boolean) => void }) { useMobileColorScheme() const t = useMobileTranslation() const { busyAction, commitMessage, setCommitMessage, generatingMessage, stagedCount, generateCommitMessage, cancelGenerateCommitMessage } = composer const ioBusy = busyAction !== null // Unmounting a focused field sends no blur, so let go of focus on the way out. useEffect(() => () => onFocusChange(false), [onFocusChange]) const shouldShowGenerateButton = stagedCount > 0 || generatingMessage // Typed text blocks regeneration; the same button cancels active generation. const generateBlockedByMessage = !generatingMessage && commitMessage.trim().length > 0 const generateDisabled = busyAction !== null || generateBlockedByMessage return ( onFocusChange(true)} onBlur={() => onFocusChange(false)} placeholder={t('sourceControl.content.commitMessagePlaceholder', 'Commit message')} editable={stagedCount > 0 && !ioBusy} accessibilityLabel={ stagedCount === 0 ? t( 'sourceControl.content.commitMessageDisabled', 'Commit message disabled. No staged files.' ) : undefined } leading={ shouldShowGenerateButton ? ( [ composerInputStyles.controlButton, generateDisabled && composerInputStyles.disabled, pressed && composerInputStyles.pressed ]} disabled={generateDisabled} accessibilityState={{ disabled: generateDisabled }} accessibilityHint={ generateBlockedByMessage ? t('sourceControl.content.regenerateHint', 'Clear the message to regenerate.') : undefined } onPress={() => generatingMessage ? cancelGenerateCommitMessage() : void generateCommitMessage() } accessibilityLabel={ generatingMessage ? t('sourceControl.content.cancelGeneration', 'Cancel commit message generation') : t('sourceControl.content.generateMessage', 'Generate commit message with AI') } > {generatingMessage ? ( ) : ( )} ) : null } trailing={ [ sourceControlStyles.commitButton, primaryAction.disabled && sourceControlStyles.commitButtonDisabled, pressed && composerInputStyles.pressed ]} onPress={primaryAction.onPress} disabled={primaryAction.disabled} accessibilityLabel={primaryAction.accessibilityLabel} accessibilityHint={primaryAction.accessibilityHint} > {primaryAction.loading ? ( ) : ( {primaryAction.label} )} } /> ) } const styles = themedStyleSheet(() => ({ bar: { position: 'absolute', left: 0, right: 0, gap: spacing.sm, paddingHorizontal: spacing.md, paddingTop: spacing.sm, backgroundColor: colors.bgBase }, reason: { color: colors.textSecondary, fontSize: typography.metaSize, textAlign: 'center' }, reasonLead: { color: colors.textPrimary, fontWeight: '600' }, reasonError: { color: colors.statusRed, fontSize: typography.metaSize, textAlign: 'center' }, row: { flexDirection: 'row', gap: spacing.sm }, grow: { flex: 1 }, button: { minHeight: 48, borderRadius: radii.control, backgroundColor: colors.surfaceBright, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing.sm, paddingHorizontal: spacing.lg }, buttonSecondary: { backgroundColor: colors.bgRaised }, buttonMerge: { backgroundColor: colors.mergeGreen }, buttonOff: { backgroundColor: colors.bgRaised }, buttonCompact: { flexGrow: 0 }, pressed: { opacity: feedback.pressedOpacity }, label: { fontSize: typography.bodySize, fontWeight: '600' }, labelSecondary: { color: colors.textPrimary }, merged: { minHeight: 48, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing.sm }, mergedText: { color: colors.textSecondary, fontSize: typography.bodySize } }))