diff --git a/src/__tests__/Home.e2e.spec.ts b/src/__tests__/Home.e2e.spec.ts
index 42280188..bffb8824 100644
--- a/src/__tests__/Home.e2e.spec.ts
+++ b/src/__tests__/Home.e2e.spec.ts
@@ -112,7 +112,17 @@ describe('Home', () => {
expect(image).toMatchImageSnapshot()
})
- it('9. should scroll to the first file in diff', async () => {
+ it('9. should show the AI prompt copy button after diff loads', async () => {
+ await page.waitForSelector(
+ `[data-testid="${upgradeButtonTestIDs.aiPromptButton}"]`
+ )
+
+ const image = await page.screenshot()
+
+ expect(image).toMatchImageSnapshot()
+ })
+
+ it('10. should scroll to the first file in diff', async () => {
await page.evaluate((testID) => {
document
.querySelector(`[data-testid="${testID}"]`)
@@ -125,7 +135,7 @@ describe('Home', () => {
expect(image).toMatchImageSnapshot()
})
- it('10. should collapse first file in diff', async () => {
+ it('11. should collapse first file in diff', async () => {
await page.click(
`[data-testid="${diffHeaderTestIDs.collapseClickableArea}"]`
)
diff --git a/src/__tests__/components/common/UpgradeButton.spec.tsx b/src/__tests__/components/common/UpgradeButton.spec.tsx
new file mode 100644
index 00000000..ca06dd72
--- /dev/null
+++ b/src/__tests__/components/common/UpgradeButton.spec.tsx
@@ -0,0 +1,30 @@
+import React from 'react'
+import { fireEvent, render, waitFor } from '@testing-library/react'
+import UpgradeButton, {
+ testIDs,
+} from '../../../components/common/UpgradeButton'
+
+describe('UpgradeButton', () => {
+ it('does not render the AI prompt button until the prompt is ready', () => {
+ const { queryByTestId } = render()
+
+ expect(queryByTestId(testIDs.aiPromptButton)).toBeNull()
+ })
+
+ it('shows the AI prompt button and triggers copy when clicked', async () => {
+ const onAiPromptClick = jest.fn().mockResolvedValue(undefined)
+ const { getByTestId } = render(
+
+ )
+
+ fireEvent.click(getByTestId(testIDs.aiPromptButton))
+
+ await waitFor(() => {
+ expect(onAiPromptClick).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/src/__tests__/utils.spec.ts b/src/__tests__/utils.spec.ts
index d01996b3..c40333b3 100644
--- a/src/__tests__/utils.spec.ts
+++ b/src/__tests__/utils.spec.ts
@@ -1,9 +1,11 @@
import { PACKAGE_NAMES } from '../constants'
+import { parseDiff } from 'react-diff-view'
import '../releases/__mocks__/index'
import {
getVersionsContentInDiff,
replaceAppDetails,
getChangelogURL,
+ buildAiUpgradePrompt,
} from '../utils'
describe('getVersionsContentInDiff', () => {
@@ -132,3 +134,117 @@ describe('replaceAppDetails ', () => {
}
)
})
+
+describe('buildAiUpgradePrompt', () => {
+ const createFiles = (rawDiffText: string) => parseDiff(rawDiffText)
+ const createPrompt = (
+ overrides: Partial[0]>
+ ) =>
+ buildAiUpgradePrompt({
+ files: [],
+ packageName: PACKAGE_NAMES.RN,
+ language: 'cpp',
+ fromVersion: '0.63.2',
+ toVersion: '0.64.2',
+ ...overrides,
+ })
+
+ it('includes overview, warnings, and structured file changes', () => {
+ const prompt = createPrompt({
+ files: createFiles(
+ [
+ 'diff --git a/RnDiffApp/App.js b/RnDiffApp/App.js',
+ '--- a/RnDiffApp/App.js',
+ '+++ b/RnDiffApp/App.js',
+ '@@ -1 +1 @@',
+ '-package com.rndiffapp;',
+ '+package com.rndiffapp;',
+ ].join('\n')
+ ),
+ appName: 'MyApp',
+ appPackage: 'com.example.myapp',
+ })
+
+ expect(prompt).toContain(
+ 'You are helping upgrade a React Native app using the provided template diff.'
+ )
+ expect(prompt).toContain('# React Native upgrade guidance request')
+ expect(prompt).toContain('## Task overview')
+ expect(prompt).toContain('- From version: 0.63.2')
+ expect(prompt).toContain('- To version: 0.64.2')
+ expect(prompt).toContain('- App name input or fallback: MyApp')
+ expect(prompt).toContain(
+ '- App package input or fallback: com.example.myapp'
+ )
+ expect(prompt).toContain(
+ 'The app name and app package values may be inaccurate because the user may have left them unchanged, blank, or approximate. Verify them against the real project before applying changes.'
+ )
+ expect(prompt).toContain(
+ 'This diff only represents the React Native bootstrap/template project between versions. First understand the current project structure and apply only the changes that are relevant to this codebase.'
+ )
+ expect(prompt).toContain('\n\n## Task overview')
+ expect(prompt).toContain('## File changes')
+ expect(prompt).toContain('\n\n### `App.js`')
+ expect(prompt).toContain('### `App.js`')
+ expect(prompt).toContain('- Change type: Modified')
+ expect(prompt).toContain('```diff')
+ expect(prompt).toContain('```')
+ expect(prompt).toContain('-package com.example.myapp;')
+ })
+
+ it('omits binary patch contents and adds download guidance', () => {
+ const prompt = createPrompt({
+ files: createFiles(
+ [
+ 'diff --git a/RnDiffApp/android/gradle/wrapper/gradle-wrapper.jar b/RnDiffApp/android/gradle/wrapper/gradle-wrapper.jar',
+ 'index abc..def 100644',
+ 'Binary files a/RnDiffApp/android/gradle/wrapper/gradle-wrapper.jar and b/RnDiffApp/android/gradle/wrapper/gradle-wrapper.jar differ',
+ 'diff --git a/RnDiffApp/App.js b/RnDiffApp/App.js',
+ '--- a/RnDiffApp/App.js',
+ '+++ b/RnDiffApp/App.js',
+ '@@ -1 +1 @@',
+ '-console.log("old")',
+ '+console.log("new")',
+ ].join('\n')
+ ),
+ })
+
+ expect(prompt).toContain('## Binary file handling')
+ expect(prompt).toContain(
+ '\n\n### `android/gradle/wrapper/gradle-wrapper.jar`'
+ )
+ expect(prompt).toContain('### `android/gradle/wrapper/gradle-wrapper.jar`')
+ expect(prompt).toContain('```bash')
+ expect(prompt).toContain(
+ 'curl -L "https://raw.githubusercontent.com/react-native-community/rn-diff-purge/release/0.64.2/RnDiffApp/android/gradle/wrapper/gradle-wrapper.jar" -o "android/gradle/wrapper/gradle-wrapper.jar"'
+ )
+ expect(prompt).not.toContain(
+ 'Binary files a/RnDiffApp/android/gradle/wrapper/gradle-wrapper.jar'
+ )
+ expect(prompt).toContain('### `App.js`')
+ })
+
+ it('tells the agent to remove deleted binary files instead of downloading them', () => {
+ const prompt = createPrompt({
+ files: createFiles(
+ [
+ 'diff --git a/RnDiffApp/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/RnDiffApp/android/app/src/main/res/mipmap-hdpi/ic_launcher.png',
+ 'deleted file mode 100644',
+ 'index abcdef..000000',
+ 'Binary files a/RnDiffApp/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ',
+ ].join('\n')
+ ),
+ })
+
+ expect(prompt).toContain(
+ '\n\n### `android/app/src/main/res/mipmap-hdpi/ic_launcher.png`'
+ )
+ expect(prompt).toContain(
+ '### `android/app/src/main/res/mipmap-hdpi/ic_launcher.png`'
+ )
+ expect(prompt).toContain(
+ '- Remove this file from the target project if it still exists.'
+ )
+ expect(prompt).not.toContain('raw.githubusercontent.com')
+ })
+})
diff --git a/src/components/common/DiffViewer.tsx b/src/components/common/DiffViewer.tsx
index cb3bbec6..071defeb 100644
--- a/src/components/common/DiffViewer.tsx
+++ b/src/components/common/DiffViewer.tsx
@@ -72,6 +72,7 @@ interface DiffViewerProps {
onToggleChangeSelection: (args: ChangeEventArgs) => void
appName: string
appPackage: string
+ onDiffResolved?: (diff: File[]) => void
}
const DiffViewer = ({
packageName,
@@ -83,6 +84,7 @@ const DiffViewer = ({
onToggleChangeSelection,
appName,
appPackage,
+ onDiffResolved,
}: DiffViewerProps) => {
const { isLoading, isDone, diff } = useFetchDiff({
shouldShowDiff,
@@ -178,6 +180,12 @@ const DiffViewer = ({
}
}, [isDone])
+ useEffect(() => {
+ if (isDone) {
+ onDiffResolved?.(diff)
+ }
+ }, [isDone, diff, onDiffResolved])
+
if (!shouldShowDiff) {
return null
}
diff --git a/src/components/common/UpgradeButton.tsx b/src/components/common/UpgradeButton.tsx
index f8358f1c..b432dee5 100644
--- a/src/components/common/UpgradeButton.tsx
+++ b/src/components/common/UpgradeButton.tsx
@@ -1,43 +1,84 @@
import React from 'react'
import styled from '@emotion/styled'
import { Button as AntdButton, ButtonProps } from 'antd'
+import { CopyOutlined } from '@ant-design/icons'
+import { deviceSizes } from '../../utils/device-sizes'
export const testIDs = {
upgradeButton: 'upgradeButton',
+ aiPromptButton: 'aiPromptButton',
}
const Container = styled.div`
display: flex;
+ align-items: center;
+ gap: 12px;
justify-content: center;
height: auto;
overflow: hidden;
margin-top: 28px;
+
+ @media ${deviceSizes.mobile} {
+ flex-direction: column;
+ }
`
const Button = styled(AntdButton)`
border-radius: 5px;
`
+const AiPromptButton = styled(Button)`
+ && {
+ background: linear-gradient(135deg, #ff6fb5 0%, #59c4ff 100%) !important;
+ border: 0;
+ box-shadow: 0 2px 0 rgba(89, 196, 255, 0.24);
+ color: #fff;
+ }
+
+ &&:hover {
+ opacity: 0.8;
+ }
+`
+
interface UpgradeButtonProps extends React.PropsWithRef {
onShowDiff: () => void
+ showAiPromptButton?: boolean
+ onAiPromptClick?: () => Promise
}
const UpgradeButton = React.forwardRef<
HTMLElement,
UpgradeButtonProps & React.RefAttributes
->(({ onShowDiff, ...props }, ref) => (
-
-
-
-))
+>(
+ (
+ { onShowDiff, showAiPromptButton = false, onAiPromptClick, ...props },
+ ref
+ ) => (
+
+
+
+ {showAiPromptButton && (
+
+ Copy for AI
+
+
+ )}
+
+ )
+)
export default UpgradeButton
diff --git a/src/components/common/VersionSelector.tsx b/src/components/common/VersionSelector.tsx
index c138a712..d7cae587 100644
--- a/src/components/common/VersionSelector.tsx
+++ b/src/components/common/VersionSelector.tsx
@@ -225,6 +225,10 @@ const VersionSelector = ({
showReleaseCandidates,
appPackage,
appName,
+ isAiPromptReady,
+ onCopyAiPrompt,
+ resolvedFromVersion,
+ resolvedToVersion,
}: {
packageName: string
language: string
@@ -233,6 +237,10 @@ const VersionSelector = ({
showReleaseCandidates: boolean
appPackage: string
appName?: string
+ isAiPromptReady: boolean
+ onCopyAiPrompt: () => Promise
+ resolvedFromVersion: string
+ resolvedToVersion: string
}) => {
const { isLoading, isDone, releaseVersions } = useFetchReleaseVersions({
packageName,
@@ -365,6 +373,10 @@ const VersionSelector = ({
appName,
})
}
+ const showAiPromptButton =
+ isAiPromptReady &&
+ localFromVersion === resolvedFromVersion &&
+ localToVersion === resolvedToVersion
return (
@@ -403,7 +415,12 @@ const VersionSelector = ({
/>
-
+
)
}
diff --git a/src/components/pages/Home.tsx b/src/components/pages/Home.tsx
index e863b624..adc3273d 100644
--- a/src/components/pages/Home.tsx
+++ b/src/components/pages/Home.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useDeferredValue } from 'react'
+import React, { useState, useEffect, useDeferredValue, useMemo } from 'react'
import styled from '@emotion/styled'
import { ThemeProvider } from '@emotion/react'
import { Card, Input, Typography, ConfigProvider, theme } from 'antd'
@@ -11,7 +11,7 @@ import DiffViewer from '../common/DiffViewer'
import Settings from '../common/Settings'
// @ts-ignore-next-line
import logo from '../../assets/logo.svg'
-import { SHOW_LATEST_RCS } from '../../utils'
+import { SHOW_LATEST_RCS, buildAiUpgradePrompt } from '../../utils'
import { useGetLanguageFromURL } from '../../hooks/get-language-from-url'
import { useGetPackageNameFromURL } from '../../hooks/get-package-name-from-url'
import {
@@ -25,6 +25,7 @@ import { updateURL } from '../../utils/update-url'
import { deviceSizes } from '../../utils/device-sizes'
import { lightTheme, darkTheme, type Theme } from '../../theme'
import { CheckboxValueType } from 'antd/es/checkbox/Group'
+import type { File } from 'gitdiff-parser'
const Page = styled.div<{ theme?: Theme }>`
background-color: ${({ theme }) => theme.background};
@@ -136,6 +137,7 @@ const Home = () => {
const [fromVersion, setFromVersion] = useState('')
const [toVersion, setToVersion] = useState('')
const [shouldShowDiff, setShouldShowDiff] = useState(false)
+ const [resolvedDiff, setResolvedDiff] = useState([])
const [settings, setSettings] = useState>({
[`${SHOW_LATEST_RCS}`]: false,
})
@@ -147,6 +149,37 @@ const Home = () => {
// Avoid UI lag when typing.
const deferredAppName = useDeferredValue(appName || DEFAULT_APP_NAME)
const deferredAppPackage = useDeferredValue(appPackage)
+ const normalizedAppPackage =
+ deferredAppPackage !== DEFAULT_APP_PACKAGE ? deferredAppPackage : undefined
+ const aiPrompt = useMemo(() => {
+ if (
+ !shouldShowDiff ||
+ !fromVersion ||
+ !toVersion ||
+ resolvedDiff.length === 0
+ ) {
+ return ''
+ }
+
+ return buildAiUpgradePrompt({
+ files: resolvedDiff,
+ packageName,
+ language,
+ fromVersion,
+ toVersion,
+ appName: deferredAppName,
+ appPackage: normalizedAppPackage,
+ })
+ }, [
+ shouldShowDiff,
+ fromVersion,
+ toVersion,
+ resolvedDiff,
+ packageName,
+ language,
+ deferredAppName,
+ normalizedAppPackage,
+ ])
const homepageUrl = process.env.PUBLIC_URL
@@ -158,18 +191,27 @@ const Home = () => {
}, [])
const handleShowDiff = ({
- fromVersion,
- toVersion,
+ fromVersion: nextFromVersion,
+ toVersion: nextToVersion,
}: {
fromVersion: string
toVersion: string
}) => {
- if (fromVersion === toVersion) {
+ if (nextFromVersion === nextToVersion) {
+ return
+ }
+
+ if (
+ shouldShowDiff &&
+ fromVersion === nextFromVersion &&
+ toVersion === nextToVersion
+ ) {
return
}
- setFromVersion(fromVersion)
- setToVersion(toVersion)
+ setFromVersion(nextFromVersion)
+ setToVersion(nextToVersion)
+ setResolvedDiff([])
setShouldShowDiff(true)
}
@@ -196,6 +238,7 @@ const Home = () => {
setLanguage(localLanguage)
setFromVersion('')
setToVersion('')
+ setResolvedDiff([])
setShouldShowDiff(false)
}
@@ -310,6 +353,10 @@ const Home = () => {
isPackageNameDefinedInURL={isPackageNameDefinedInURL}
appPackage={appPackage}
appName={appName}
+ isAiPromptReady={!!aiPrompt}
+ onCopyAiPrompt={() => navigator.clipboard.writeText(aiPrompt)}
+ resolvedFromVersion={fromVersion}
+ resolvedToVersion={toVersion}
/>
{/*
@@ -330,6 +377,7 @@ const Home = () => {
}
packageName={packageName}
language={language}
+ onDiffResolved={setResolvedDiff}
/>
diff --git a/src/utils.ts b/src/utils.ts
index 08030885..ad25707d 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,4 +1,5 @@
import semver from 'semver/preload'
+import type { Change, File, Hunk } from 'gitdiff-parser'
import {
RN_DIFF_REPOSITORIES,
DEFAULT_APP_NAME,
@@ -178,3 +179,188 @@ export const getFilePathsToShow = ({
newPath: removeAppPathPrefix(newPathSanitized, appName),
}
}
+
+const isBinaryFile = (file: File) =>
+ !!file.isBinary ||
+ (file.hunks.length === 0 && !['rename', 'copy'].includes(file.type))
+
+const isDeletedBinaryFile = (file: File) => {
+ return (
+ isBinaryFile(file) &&
+ (file.type === 'delete' || file.newRevision === '000000')
+ )
+}
+
+const NO_LINE_LEVEL_PATCH_MESSAGE =
+ '- No line-level patch was included for this file.'
+
+const serializeChange = ({
+ change,
+ appName,
+ appPackage,
+}: {
+ change: Change
+ appName?: string
+ appPackage?: string
+}) => {
+ const prefix =
+ change.type === 'insert' ? '+' : change.type === 'delete' ? '-' : ' '
+
+ return `${prefix}${replaceAppDetails(change.content, appName, appPackage)}`
+}
+
+const serializeHunk = ({
+ hunk,
+ appName,
+ appPackage,
+}: {
+ hunk: Hunk
+ appName?: string
+ appPackage?: string
+}) =>
+ [hunk.content]
+ .concat(
+ hunk.changes.map((change) =>
+ serializeChange({ change, appName, appPackage })
+ )
+ )
+ .join('\n')
+
+const FILE_CHANGE_LABELS: Record = {
+ add: 'Added',
+ copy: 'Copied',
+ delete: 'Deleted',
+ modify: 'Modified',
+ rename: 'Renamed',
+}
+
+export const buildAiUpgradePrompt = ({
+ files,
+ packageName,
+ language,
+ fromVersion,
+ toVersion,
+ appName,
+ appPackage,
+}: {
+ files: File[]
+ packageName: string
+ language: string
+ fromVersion: string
+ toVersion: string
+ appName?: string
+ appPackage?: string
+}) => {
+ const effectiveAppName = appName || DEFAULT_APP_NAME
+ const effectiveAppPackage =
+ appPackage || `com.${effectiveAppName.toLowerCase()}`
+ const binaryFiles = files.filter(isBinaryFile)
+ const textFiles = files.filter((file) => !isBinaryFile(file))
+ const binaryInstructions =
+ binaryFiles.length > 0
+ ? [
+ '',
+ '## Binary file handling',
+ '- Binary files are listed separately because inline patch payloads are not useful here.',
+ ...binaryFiles.flatMap((file) => {
+ const localPaths = getFilePathsToShow({
+ oldPath: file.oldPath,
+ newPath: file.newPath,
+ appName,
+ appPackage,
+ })
+ const localPath = isDeletedBinaryFile(file)
+ ? localPaths.oldPath
+ : localPaths.newPath
+
+ if (isDeletedBinaryFile(file)) {
+ return [
+ '',
+ `### \`${localPath}\``,
+ '- Remove this file from the target project if it still exists.',
+ ]
+ }
+
+ const downloadURL = getBinaryFileURL({
+ packageName,
+ language,
+ version: toVersion,
+ path: file.newPath,
+ })
+
+ return [
+ '',
+ `### \`${localPath}\``,
+ '```bash',
+ `curl -L "${downloadURL}" -o "${localPath}"`,
+ '```',
+ ]
+ }),
+ ]
+ : []
+ const structuredFileChanges =
+ textFiles.length > 0
+ ? [
+ '',
+ '## File changes',
+ ...textFiles.flatMap((file) => {
+ const localPaths = getFilePathsToShow({
+ oldPath: file.oldPath,
+ newPath: file.newPath,
+ appName,
+ appPackage,
+ })
+ const localPath =
+ file.type === 'delete' ? localPaths.oldPath : localPaths.newPath
+ const fileChanges = [
+ `### \`${localPath}\``,
+ `- Change type: ${FILE_CHANGE_LABELS[file.type]}`,
+ ]
+
+ if (
+ ['rename', 'copy'].includes(file.type) &&
+ localPaths.oldPath !== localPaths.newPath
+ ) {
+ fileChanges.push(`- Previous path: \`${localPaths.oldPath}\``)
+ }
+
+ if (file.hunks.length > 0) {
+ fileChanges.push(
+ '```diff',
+ file.hunks
+ .map((hunk) => serializeHunk({ hunk, appName, appPackage }))
+ .join('\n'),
+ '```'
+ )
+ } else {
+ fileChanges.push(NO_LINE_LEVEL_PATCH_MESSAGE)
+ }
+
+ return ['', ...fileChanges]
+ }),
+ ]
+ : []
+
+ return [
+ '# React Native upgrade guidance request',
+ '',
+ 'You are helping upgrade a React Native app using the provided template diff.',
+ '',
+ '## Task overview',
+ `- From version: ${fromVersion}`,
+ `- To version: ${toVersion}`,
+ `- App name input or fallback: ${effectiveAppName}`,
+ `- App package input or fallback: ${effectiveAppPackage}`,
+ '',
+ '## Important notes',
+ '- Use the structured file changes below as the source of truth for parsed template changes.',
+ '- This diff only represents the React Native bootstrap/template project between versions. First understand the current project structure and apply only the changes that are relevant to this codebase.',
+ '- Preserve project-specific code and merge carefully instead of blindly overwriting files.',
+ '- Review binary file changes separately if any are present.',
+ '- The app name and app package values may be inaccurate because the user may have left them unchanged, blank, or approximate. Verify them against the real project before applying changes.',
+ ...binaryInstructions,
+ ...structuredFileChanges,
+ ]
+ .filter((line) => line !== undefined)
+ .join('\n')
+}
diff --git a/src/utils/device-sizes.ts b/src/utils/device-sizes.ts
index 80780b3e..eb2045b4 100644
--- a/src/utils/device-sizes.ts
+++ b/src/utils/device-sizes.ts
@@ -1,3 +1,4 @@
export const deviceSizes = {
+ mobile: '(max-width: 640px)',
tablet: '(min-width: 768px)',
}