11.6 KB392 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 {Button} from 'isl-components/Button';
9import {Icon} from 'isl-components/Icon';
10import {Tooltip} from 'isl-components/Tooltip';
11import {useAtomValue} from 'jotai';
12import {Suspense, useState} from 'react';
13import {randomId} from 'shared/utils';
14import {tracker} from '../analytics';
15import serverAPI from '../ClientToServerAPI';
16import {diffCommentData} from '../codeReview/codeReviewAtoms';
17import {diffSummary} from '../codeReview/CodeReviewInfo';
18import {DropdownFields} from '../DropdownFields';
19import {useFeatureFlagAsync, useFeatureFlagSync} from '../featureFlags';
20import {T} from '../i18n';
21import {Internal} from '../Internal';
22import {BaseSplitButton} from '../stackEdit/ui/BaseSplitButton';
23import type {CommitInfo} from '../types';
24
25import platform from '../platform';
26import {repositoryInfo} from '../serverAPIState';
27import './SmartActionsMenu.css';
28
29export function SmartActionsMenu({commit}: {commit?: CommitInfo}) {
30 const [dropdownVisible, setDropdownVisible] = useState(false);
31
32 const smartActionsMenuEnabled = useFeatureFlagSync(Internal.featureFlags?.SmartActionsMenu);
33 if (!smartActionsMenuEnabled || !Internal.smartActions?.showSmartActions) {
34 return null;
35 }
36
37 return (
38 <Tooltip
39 component={dismiss => {
40 return (
41 <Suspense fallback={<Icon icon="loading" />}>
42 <SmartActions commit={commit} dismiss={dismiss} />
43 </Suspense>
44 );
45 }}
46 trigger="click"
47 title={<T>Smart Actions...</T>}
48 onVisible={() => setDropdownVisible(true)}
49 onDismiss={() => setDropdownVisible(false)}
50 group="smart-actions">
51 <Button
52 icon
53 data-testid="smart-actions-button"
54 onClick={() => tracker.track('SmartActionsMenuOpened')}
55 className={'smart-actions-button' + (dropdownVisible ? ' dropdown-visible' : '')}>
56 <Icon icon="lightbulb-sparkle" />
57 </Button>
58 </Tooltip>
59 );
60}
61
62function SmartActions({commit, dismiss}: {commit?: CommitInfo; dismiss: () => void}) {
63 const actions = [];
64
65 const aiCommitSplitEnabled = useFeatureFlagAsync(Internal.featureFlags?.AICommitSplit);
66 if (commit && aiCommitSplitEnabled) {
67 actions.push(<AutoSplitButton key="auto-split" commit={commit} dismiss={dismiss} />);
68 }
69
70 const aiResolveCommentsEnabled = useFeatureFlagAsync(
71 Internal.featureFlags?.InlineCommentAIResolve,
72 );
73 // For now, only support this in VS Code
74 if (aiResolveCommentsEnabled && commit?.diffId && platform.platformName === 'vscode') {
75 actions.push(
76 <ResolveCommentsButton
77 key="resolve-comments"
78 diffId={commit.diffId}
79 filePathsSample={commit.filePathsSample}
80 dismiss={dismiss}
81 disabled={!commit.isDot}
82 disabledReason="This action is only available for the current commit."
83 />,
84 );
85 }
86
87 const aiResolveFailedSignalsEnabled = useFeatureFlagAsync(
88 Internal.featureFlags?.AIResolveFailedSignals,
89 );
90 // For now, only support this in VS Code
91 if (aiResolveFailedSignalsEnabled && commit?.diffId && platform.platformName === 'vscode') {
92 actions.push(
93 <ResolveFailedSignalsButton
94 key="resolve-failed-signals"
95 hash={commit.hash}
96 diffId={commit.diffId}
97 dismiss={dismiss}
98 disabled={!commit.isDot}
99 disabledReason="This action is only available for the current commit."
100 />,
101 );
102 }
103
104 const aiGenerateTestsForModifiedCodeEnabled = useFeatureFlagAsync(
105 Internal.featureFlags?.AIGenerateTestsForModifiedCode,
106 );
107 // For now, only support this in VS Code
108 if (aiGenerateTestsForModifiedCodeEnabled && platform.platformName === 'vscode') {
109 const enabled = !commit || commit.isDot; // Enabled for `uncommitted changes` or the `current commit`.
110 actions.push(
111 <GenerateTestsForModifiedCodeButton
112 key="generate-tests"
113 dismiss={dismiss}
114 disabled={!enabled}
115 disabledReason="This action is only available for the current commit and uncommitted changes."
116 />,
117 );
118 }
119
120 const aiGenerateCommitMessageEnabled = useFeatureFlagAsync(
121 Internal.featureFlags?.AIGenerateCommitMessage,
122 );
123 // For now, only support this in VS Code
124 if (!commit && aiGenerateCommitMessageEnabled && platform.platformName === 'vscode') {
125 actions.push(<FillCommitInfoButton key="fill-commit-info" dismiss={dismiss} />);
126 }
127
128 const aiValidateChangesEnabled = useFeatureFlagAsync(Internal.featureFlags?.AIValidateChanges);
129 // For now, only support this in VS Code
130 if (!commit && aiValidateChangesEnabled && platform.platformName === 'vscode') {
131 actions.push(<ValidateChangesButton key="validate-changes" dismiss={dismiss} />);
132 }
133
134 const aiCodeReviewUpsellEnabled = useFeatureFlagAsync(Internal.featureFlags?.AICodeReviewUpsell);
135 // For now, only support this in VS Code
136 if (aiCodeReviewUpsellEnabled && platform.platformName === 'vscode') {
137 const enabled = !commit || commit.isDot; // Enabled for `uncommitted changes` or the `current commit`.
138 actions.push(
139 <ReviewCodeButton
140 key="review-commit"
141 commit={commit}
142 dismiss={dismiss}
143 disabled={!enabled}
144 disabledReason="This action is only available for the current commit and uncommitted changes."
145 />,
146 );
147 }
148
149 return (
150 <DropdownFields
151 title={<T>Smart Actions</T>}
152 icon="lightbulb-sparkle"
153 className="smart-actions-dropdown"
154 data-testid="smart-actions-dropdown">
155 {actions.length > 0 ? actions : <T>No smart actions available</T>}
156 </DropdownFields>
157 );
158}
159
160/** Like SplitButton, but triggers AI split automatically. */
161function AutoSplitButton({commit, dismiss}: {commit: CommitInfo; dismiss: () => void}) {
162 return (
163 <BaseSplitButton
164 commit={commit}
165 trackerEventName="SplitOpenFromSmartActions"
166 autoSplit={true}
167 onSplitInitiated={() => {
168 tracker.track('SmartActionClicked', {extras: {action: 'AutoSplit'}});
169 dismiss();
170 }}>
171 <Icon icon="sparkle" />
172 <T>Auto-split</T>
173 </BaseSplitButton>
174 );
175}
176
177/** Prompt AI to resolve all comments on a diff. */
178function ResolveCommentsButton({
179 diffId,
180 filePathsSample,
181 dismiss,
182 disabled,
183 disabledReason,
184}: {
185 diffId: string;
186 filePathsSample: readonly string[];
187 dismiss: () => void;
188 disabled?: boolean;
189 disabledReason?: string;
190}) {
191 const repo = useAtomValue(repositoryInfo);
192 const repoPath = repo?.repoRoot;
193 const diffComments = useAtomValue(diffCommentData(diffId));
194 if (diffComments.state === 'loading') {
195 return <Icon icon="loading" />;
196 }
197 if (diffComments.state === 'hasError' || diffComments.data.length === 0) {
198 return;
199 }
200
201 const button = (
202 <Button
203 data-testid="review-comments-button"
204 onClick={e => {
205 tracker.track('SmartActionClicked', {extras: {action: 'ResolveAllComments'}});
206 serverAPI.postMessage({
207 type: 'platform/resolveAllCommentsWithAI',
208 diffId,
209 comments: diffComments.data,
210 filePaths: [...filePathsSample],
211 repoPath,
212 });
213 dismiss();
214 e.stopPropagation();
215 }}
216 disabled={disabled}>
217 <Icon icon="sparkle" />
218 <T>Resolve all comments</T>
219 </Button>
220 );
221
222 return disabled ? <Tooltip title={disabledReason}>{button}</Tooltip> : button;
223}
224
225/** Prompt AI to fill commit info. */
226function FillCommitInfoButton({dismiss}: {dismiss: () => void}) {
227 return (
228 <Button
229 data-testid="fill-commit-info-button"
230 onClick={e => {
231 tracker.track('SmartActionClicked', {extras: {action: 'FillCommitMessage'}});
232 serverAPI.postMessage({
233 type: 'platform/fillCommitMessageWithAI',
234 id: randomId(),
235 source: 'smartAction',
236 });
237 dismiss();
238 e.stopPropagation();
239 }}>
240 <Icon icon="sparkle" />
241 <T>Fill commit info</T>
242 </Button>
243 );
244}
245
246/** Prompt AI to resolve failed signals on a diff. */
247function ResolveFailedSignalsButton({
248 hash,
249 diffId,
250 dismiss,
251 disabled,
252 disabledReason,
253}: {
254 hash: string;
255 diffId: string;
256 dismiss: () => void;
257 disabled?: boolean;
258 disabledReason?: string;
259}) {
260 const repo = useAtomValue(repositoryInfo);
261 const repoPath = repo?.repoRoot;
262 const diffSummaryResult = useAtomValue(diffSummary(diffId));
263
264 // Only show the button if there are failed signals
265 if (
266 diffSummaryResult.error ||
267 !diffSummaryResult.value?.signalSummary ||
268 diffSummaryResult.value.signalSummary !== 'failed'
269 ) {
270 return null;
271 }
272
273 const diffVersionNumber = Internal.getDiffVersionNumber?.(diffSummaryResult.value, hash);
274
275 const button = (
276 <Button
277 data-testid="resolve-failed-signals-button"
278 onClick={e => {
279 if (diffVersionNumber !== undefined) {
280 tracker.track('SmartActionClicked', {extras: {action: 'ResolveFailedSignals'}});
281 serverAPI.postMessage({
282 type: 'platform/resolveFailedSignalsWithAI',
283 diffId,
284 diffVersionNumber,
285 repoPath,
286 });
287 dismiss();
288 }
289 e.stopPropagation();
290 }}
291 disabled={disabled || diffVersionNumber === undefined}>
292 <Icon icon="sparkle" />
293 <T>Fix failed signals</T>
294 </Button>
295 );
296
297 return disabled || diffVersionNumber === undefined ? (
298 <Tooltip
299 title={
300 diffVersionNumber === undefined
301 ? 'Unable to determine Phabricator version number for this commit'
302 : disabledReason
303 }>
304 {button}
305 </Tooltip>
306 ) : (
307 button
308 );
309}
310
311function GenerateTestsForModifiedCodeButton({
312 dismiss,
313 disabled,
314 disabledReason,
315}: {
316 dismiss: () => void;
317 disabled?: boolean;
318 disabledReason?: string;
319}) {
320 const button = (
321 <Button
322 data-testid="generate-tests-for-modified-code-button"
323 onClick={e => {
324 tracker.track('SmartActionClicked', {extras: {action: 'GenerateTests'}});
325 serverAPI.postMessage({
326 type: 'platform/createTestForModifiedCodeWithAI',
327 });
328 dismiss();
329 e.stopPropagation();
330 }}
331 disabled={disabled}>
332 <Icon icon="sparkle" />
333 <T>Generate tests for changes</T>
334 </Button>
335 );
336
337 return disabled ? <Tooltip title={disabledReason}>{button}</Tooltip> : button;
338}
339
340/** Prompt AI to validate code and fix errors in the working copy. */
341function ValidateChangesButton({dismiss}: {dismiss: () => void}) {
342 return (
343 <Button
344 data-testid="validate-changes-button"
345 onClick={e => {
346 tracker.track('SmartActionClicked', {extras: {action: 'ValidateChanges'}});
347 serverAPI.postMessage({
348 type: 'platform/validateChangesWithAI',
349 });
350 dismiss();
351 e.stopPropagation();
352 }}>
353 <Icon icon="sparkle" />
354 <T>Validate Changes</T>
355 </Button>
356 );
357}
358
359/** Prompt AI to review the current commit and add comments */
360function ReviewCodeButton({
361 commit,
362 dismiss,
363 disabled,
364 disabledReason,
365}: {
366 commit?: CommitInfo;
367 dismiss: () => void;
368 disabled?: boolean;
369 disabledReason?: string;
370}) {
371 const button = (
372 <Button
373 data-testid="review-commit-button"
374 onClick={e => {
375 tracker.track('SmartActionClicked', {extras: {action: 'ReviewCommit'}});
376 serverAPI.postMessage({
377 type: 'platform/runAICodeReviewChat',
378 source: 'smartAction',
379 reviewScope: commit ? 'current commit' : 'uncommitted changes',
380 });
381 dismiss();
382 e.stopPropagation();
383 }}
384 disabled={disabled}>
385 <Icon icon="sparkle" />
386 <T>{commit ? 'Review commit' : 'Review changes'}</T>
387 </Button>
388 );
389
390 return disabled ? <Tooltip title={disabledReason}>{button}</Tooltip> : button;
391}
392