8.9 KB276 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {CommitRev, CommitStackState} from '../commitStackState';
9import type {DiffCommit, PartiallySelectedDiffCommit} from '../diffSplitTypes';
10import {
11 bumpStackEditMetric,
12 findStartEndRevs,
13 SplitRangeRecord,
14 type UseStackEditState,
15} from './stackEditState';
16
17import * as stylex from '@stylexjs/stylex';
18import {Button} from 'isl-components/Button';
19import {ButtonWithDropdownTooltip} from 'isl-components/ButtonWithDropdownTooltip';
20import {InlineErrorBadge} from 'isl-components/ErrorNotice';
21import {Icon} from 'isl-components/Icon';
22import {Tooltip} from 'isl-components/Tooltip';
23import {type ForwardedRef, forwardRef, useEffect, useRef, useState} from 'react';
24import {randomId} from 'shared/utils';
25import {MinHeightTextField} from '../../CommitInfoView/MinHeightTextField';
26import {Column} from '../../ComponentUtils';
27import {useGeneratedFileStatuses} from '../../GeneratedFile';
28import {Internal} from '../../Internal';
29import {tracker} from '../../analytics';
30import {useFeatureFlagSync} from '../../featureFlags';
31import {t, T} from '../../i18n';
32import {GeneratedStatus} from '../../types';
33import {applyDiffSplit, diffCommit} from '../diffSplit';
34import {next} from '../revMath';
35
36const styles = stylex.create({
37 full: {
38 minWidth: '300px',
39 width: '100%',
40 },
41});
42
43type AISplitButtonProps = {
44 stackEdit: UseStackEditState;
45 commitStack: CommitStackState;
46 subStack: CommitStackState;
47 rev: CommitRev;
48};
49
50type AISplitButtonLoadingState =
51 | {type: 'READY'}
52 | {type: 'LOADING'; id: string}
53 | {type: 'ERROR'; error: Error};
54
55export const AISplitButton = forwardRef(
56 (
57 {stackEdit, commitStack, subStack, rev}: AISplitButtonProps,
58 ref: ForwardedRef<HTMLButtonElement>,
59 ) => {
60 const {splitCommitWithAI} = Internal;
61 const enableAICommitSplit =
62 useFeatureFlagSync(Internal.featureFlags?.AICommitSplit) && splitCommitWithAI != null;
63
64 const [loadingState, setLoadingState] = useState<AISplitButtonLoadingState>({type: 'READY'});
65
66 // Reset state if commitStack changes while in LOADING state. E.g., user manually updated commits locally.
67 useEffect(() => {
68 if (loadingState.type === 'LOADING') {
69 setLoadingState({type: 'READY'});
70 }
71 return () => {
72 // Cancel loading state when unmounted
73 setLoadingState({type: 'READY'});
74 };
75 // eslint-disable-next-line react-hooks/exhaustive-deps
76 }, [commitStack]); // Triggered when commitStack changes
77
78 const applyNewDiffSplitCommits = (
79 subStack: CommitStackState,
80 rev: CommitRev,
81 commits: ReadonlyArray<PartiallySelectedDiffCommit>,
82 ) => {
83 const [startRev, endRev] = findStartEndRevs(stackEdit);
84 if (startRev != null && endRev != null) {
85 // Replace the current, single rev with the new stack, which might have multiple revs.
86 const newSubStack = applyDiffSplit(subStack, rev, commits);
87 // Replace the [start, end+1] range with the new stack in the commit stack.
88 const newCommitStack = commitStack.applySubStack(startRev, next(endRev), newSubStack);
89 // Find the new split range.
90 const endOffset = newCommitStack.size - commitStack.size;
91 const startKey = newCommitStack.get(rev)?.key ?? '';
92 const endKey = newCommitStack.get(next(rev, endOffset))?.key ?? '';
93 const splitRange = SplitRangeRecord({startKey, endKey});
94 // Update the main stack state.
95 stackEdit.push(newCommitStack, {name: 'splitWithAI'}, splitRange);
96 }
97 };
98
99 const diffWithoutGeneratedFiles = useDiffWithoutGeneratedFiles(subStack, rev);
100
101 const [guidanceToAI, setGuidanceToAI] = useState('');
102 const fetch = async () => {
103 if (loadingState.type === 'LOADING' || splitCommitWithAI == null) {
104 return;
105 }
106 if (diffWithoutGeneratedFiles.files.length === 0) {
107 return;
108 }
109
110 bumpStackEditMetric('clickedAiSplit');
111
112 const id = randomId();
113 setLoadingState({type: 'LOADING', id});
114 const args =
115 guidanceToAI == null || guidanceToAI.trim() === ''
116 ? {}
117 : {
118 user_prompt: guidanceToAI.trim(),
119 };
120
121 try {
122 const result: ReadonlyArray<PartiallySelectedDiffCommit> = await tracker.operation(
123 'AISplitButtonClick',
124 'SplitSuggestionError',
125 undefined,
126 () => splitCommitWithAI(diffWithoutGeneratedFiles, args),
127 );
128 setLoadingState(prev => {
129 if (prev.type === 'LOADING' && prev.id === id) {
130 const commits = result.filter(c => c.files.length > 0);
131 if (commits.length > 0) {
132 applyNewDiffSplitCommits(subStack, rev, commits);
133 }
134 return {type: 'READY'};
135 }
136 return prev;
137 });
138 } catch (err) {
139 if (err != null) {
140 setLoadingState(prev => {
141 if (prev.type === 'LOADING' && prev.id === id) {
142 return {type: 'ERROR', error: err as Error};
143 }
144 return prev;
145 });
146 return;
147 }
148 }
149 };
150
151 const cancel = () => {
152 setLoadingState(prev => {
153 const {type} = prev;
154 if (type === 'LOADING' || type === 'ERROR') {
155 return {type: 'READY'};
156 }
157 return prev;
158 });
159 };
160
161 if (!enableAICommitSplit) {
162 return null;
163 }
164
165 switch (loadingState.type) {
166 case 'READY':
167 return (
168 <Tooltip title={t('Split this commit using AI')} placement="bottom">
169 <ButtonWithDropdownTooltip
170 label={<T>AI Split</T>}
171 data-testid="cwd-dropdown-button"
172 kind="icon"
173 icon={<Icon icon="sparkle" />}
174 onClick={fetch}
175 ref={ref}
176 tooltip={
177 <DetailsDropdown
178 loadingState={loadingState}
179 submit={fetch}
180 guidanceToAI={guidanceToAI}
181 setGuidanceToAI={setGuidanceToAI}
182 />
183 }
184 />
185 </Tooltip>
186 );
187 case 'LOADING':
188 return (
189 <Tooltip title={t('Split is working, click to cancel')} placement="bottom">
190 <ButtonWithDropdownTooltip
191 label={<T>Splitting</T>}
192 data-testid="cwd-dropdown-button"
193 kind="icon"
194 icon={<Icon icon="loading" />}
195 onClick={cancel}
196 tooltip={
197 <DetailsDropdown
198 loadingState={loadingState}
199 submit={cancel}
200 guidanceToAI={guidanceToAI}
201 setGuidanceToAI={setGuidanceToAI}
202 />
203 }
204 />
205 </Tooltip>
206 );
207 case 'ERROR':
208 return (
209 <Column alignStart>
210 <ButtonWithDropdownTooltip
211 label={<T>AI Split</T>}
212 data-testid="cwd-dropdown-button"
213 kind="icon"
214 icon={<Icon icon="sparkle" />}
215 onClick={fetch}
216 tooltip={
217 <DetailsDropdown
218 loadingState={loadingState}
219 submit={fetch}
220 guidanceToAI={guidanceToAI}
221 setGuidanceToAI={setGuidanceToAI}
222 />
223 }
224 />
225 <InlineErrorBadge error={loadingState.error} placement="bottom">
226 <T>AI Split Failed</T>
227 </InlineErrorBadge>
228 </Column>
229 );
230 }
231 },
232);
233
234function useDiffWithoutGeneratedFiles(subStack: CommitStackState, rev: CommitRev): DiffCommit {
235 const diffForAllFiles = diffCommit(subStack, rev);
236 const allFilePaths = diffForAllFiles.files.map(f => f.bPath);
237 const generatedFileStatuses = useGeneratedFileStatuses(allFilePaths);
238 const filesWithoutGeneratedFiles = diffForAllFiles.files.filter(
239 f => generatedFileStatuses[f.bPath] !== GeneratedStatus.Generated,
240 );
241 return {
242 ...diffForAllFiles,
243 files: filesWithoutGeneratedFiles,
244 };
245}
246
247function DetailsDropdown({
248 loadingState,
249 submit,
250 guidanceToAI,
251 setGuidanceToAI,
252}: {
253 loadingState: AISplitButtonLoadingState;
254 submit: () => unknown;
255 guidanceToAI: string;
256 setGuidanceToAI: React.Dispatch<React.SetStateAction<string>>;
257}) {
258 const ref = useRef(null);
259 return (
260 <Column alignStart>
261 <MinHeightTextField
262 ref={ref}
263 keepNewlines
264 containerXstyle={styles.full}
265 value={guidanceToAI}
266 onInput={e => setGuidanceToAI(e.currentTarget.value)}>
267 <T>Provide additional instructions to AI (optional)</T>
268 </MinHeightTextField>
269 <Button onClick={submit} style={{alignSelf: 'end'}} primary>
270 {loadingState.type === 'LOADING' ? <Icon icon="loading" /> : <Icon icon="sparkle" />}
271 {loadingState.type === 'LOADING' ? <T>Splitting</T> : <T>AI Split</T>}
272 </Button>
273 </Column>
274 );
275}
276