addons/isl/src/stackEdit/ui/AISplit.tsxblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {CommitRev, CommitStackState} from '../commitStackState';
b69ab319import type {DiffCommit, PartiallySelectedDiffCommit} from '../diffSplitTypes';
b69ab3110import {
b69ab3111 bumpStackEditMetric,
b69ab3112 findStartEndRevs,
b69ab3113 SplitRangeRecord,
b69ab3114 type UseStackEditState,
b69ab3115} from './stackEditState';
b69ab3116
b69ab3117import * as stylex from '@stylexjs/stylex';
b69ab3118import {Button} from 'isl-components/Button';
b69ab3119import {ButtonWithDropdownTooltip} from 'isl-components/ButtonWithDropdownTooltip';
b69ab3120import {InlineErrorBadge} from 'isl-components/ErrorNotice';
b69ab3121import {Icon} from 'isl-components/Icon';
b69ab3122import {Tooltip} from 'isl-components/Tooltip';
b69ab3123import {type ForwardedRef, forwardRef, useEffect, useRef, useState} from 'react';
b69ab3124import {randomId} from 'shared/utils';
b69ab3125import {MinHeightTextField} from '../../CommitInfoView/MinHeightTextField';
b69ab3126import {Column} from '../../ComponentUtils';
b69ab3127import {useGeneratedFileStatuses} from '../../GeneratedFile';
b69ab3128import {Internal} from '../../Internal';
b69ab3129import {tracker} from '../../analytics';
b69ab3130import {useFeatureFlagSync} from '../../featureFlags';
b69ab3131import {t, T} from '../../i18n';
b69ab3132import {GeneratedStatus} from '../../types';
b69ab3133import {applyDiffSplit, diffCommit} from '../diffSplit';
b69ab3134import {next} from '../revMath';
b69ab3135
b69ab3136const styles = stylex.create({
b69ab3137 full: {
b69ab3138 minWidth: '300px',
b69ab3139 width: '100%',
b69ab3140 },
b69ab3141});
b69ab3142
b69ab3143type AISplitButtonProps = {
b69ab3144 stackEdit: UseStackEditState;
b69ab3145 commitStack: CommitStackState;
b69ab3146 subStack: CommitStackState;
b69ab3147 rev: CommitRev;
b69ab3148};
b69ab3149
b69ab3150type AISplitButtonLoadingState =
b69ab3151 | {type: 'READY'}
b69ab3152 | {type: 'LOADING'; id: string}
b69ab3153 | {type: 'ERROR'; error: Error};
b69ab3154
b69ab3155export const AISplitButton = forwardRef(
b69ab3156 (
b69ab3157 {stackEdit, commitStack, subStack, rev}: AISplitButtonProps,
b69ab3158 ref: ForwardedRef<HTMLButtonElement>,
b69ab3159 ) => {
b69ab3160 const {splitCommitWithAI} = Internal;
b69ab3161 const enableAICommitSplit =
b69ab3162 useFeatureFlagSync(Internal.featureFlags?.AICommitSplit) && splitCommitWithAI != null;
b69ab3163
b69ab3164 const [loadingState, setLoadingState] = useState<AISplitButtonLoadingState>({type: 'READY'});
b69ab3165
b69ab3166 // Reset state if commitStack changes while in LOADING state. E.g., user manually updated commits locally.
b69ab3167 useEffect(() => {
b69ab3168 if (loadingState.type === 'LOADING') {
b69ab3169 setLoadingState({type: 'READY'});
b69ab3170 }
b69ab3171 return () => {
b69ab3172 // Cancel loading state when unmounted
b69ab3173 setLoadingState({type: 'READY'});
b69ab3174 };
b69ab3175 // eslint-disable-next-line react-hooks/exhaustive-deps
b69ab3176 }, [commitStack]); // Triggered when commitStack changes
b69ab3177
b69ab3178 const applyNewDiffSplitCommits = (
b69ab3179 subStack: CommitStackState,
b69ab3180 rev: CommitRev,
b69ab3181 commits: ReadonlyArray<PartiallySelectedDiffCommit>,
b69ab3182 ) => {
b69ab3183 const [startRev, endRev] = findStartEndRevs(stackEdit);
b69ab3184 if (startRev != null && endRev != null) {
b69ab3185 // Replace the current, single rev with the new stack, which might have multiple revs.
b69ab3186 const newSubStack = applyDiffSplit(subStack, rev, commits);
b69ab3187 // Replace the [start, end+1] range with the new stack in the commit stack.
b69ab3188 const newCommitStack = commitStack.applySubStack(startRev, next(endRev), newSubStack);
b69ab3189 // Find the new split range.
b69ab3190 const endOffset = newCommitStack.size - commitStack.size;
b69ab3191 const startKey = newCommitStack.get(rev)?.key ?? '';
b69ab3192 const endKey = newCommitStack.get(next(rev, endOffset))?.key ?? '';
b69ab3193 const splitRange = SplitRangeRecord({startKey, endKey});
b69ab3194 // Update the main stack state.
b69ab3195 stackEdit.push(newCommitStack, {name: 'splitWithAI'}, splitRange);
b69ab3196 }
b69ab3197 };
b69ab3198
b69ab3199 const diffWithoutGeneratedFiles = useDiffWithoutGeneratedFiles(subStack, rev);
b69ab31100
b69ab31101 const [guidanceToAI, setGuidanceToAI] = useState('');
b69ab31102 const fetch = async () => {
b69ab31103 if (loadingState.type === 'LOADING' || splitCommitWithAI == null) {
b69ab31104 return;
b69ab31105 }
b69ab31106 if (diffWithoutGeneratedFiles.files.length === 0) {
b69ab31107 return;
b69ab31108 }
b69ab31109
b69ab31110 bumpStackEditMetric('clickedAiSplit');
b69ab31111
b69ab31112 const id = randomId();
b69ab31113 setLoadingState({type: 'LOADING', id});
b69ab31114 const args =
b69ab31115 guidanceToAI == null || guidanceToAI.trim() === ''
b69ab31116 ? {}
b69ab31117 : {
b69ab31118 user_prompt: guidanceToAI.trim(),
b69ab31119 };
b69ab31120
b69ab31121 try {
b69ab31122 const result: ReadonlyArray<PartiallySelectedDiffCommit> = await tracker.operation(
b69ab31123 'AISplitButtonClick',
b69ab31124 'SplitSuggestionError',
b69ab31125 undefined,
b69ab31126 () => splitCommitWithAI(diffWithoutGeneratedFiles, args),
b69ab31127 );
b69ab31128 setLoadingState(prev => {
b69ab31129 if (prev.type === 'LOADING' && prev.id === id) {
b69ab31130 const commits = result.filter(c => c.files.length > 0);
b69ab31131 if (commits.length > 0) {
b69ab31132 applyNewDiffSplitCommits(subStack, rev, commits);
b69ab31133 }
b69ab31134 return {type: 'READY'};
b69ab31135 }
b69ab31136 return prev;
b69ab31137 });
b69ab31138 } catch (err) {
b69ab31139 if (err != null) {
b69ab31140 setLoadingState(prev => {
b69ab31141 if (prev.type === 'LOADING' && prev.id === id) {
b69ab31142 return {type: 'ERROR', error: err as Error};
b69ab31143 }
b69ab31144 return prev;
b69ab31145 });
b69ab31146 return;
b69ab31147 }
b69ab31148 }
b69ab31149 };
b69ab31150
b69ab31151 const cancel = () => {
b69ab31152 setLoadingState(prev => {
b69ab31153 const {type} = prev;
b69ab31154 if (type === 'LOADING' || type === 'ERROR') {
b69ab31155 return {type: 'READY'};
b69ab31156 }
b69ab31157 return prev;
b69ab31158 });
b69ab31159 };
b69ab31160
b69ab31161 if (!enableAICommitSplit) {
b69ab31162 return null;
b69ab31163 }
b69ab31164
b69ab31165 switch (loadingState.type) {
b69ab31166 case 'READY':
b69ab31167 return (
b69ab31168 <Tooltip title={t('Split this commit using AI')} placement="bottom">
b69ab31169 <ButtonWithDropdownTooltip
b69ab31170 label={<T>AI Split</T>}
b69ab31171 data-testid="cwd-dropdown-button"
b69ab31172 kind="icon"
b69ab31173 icon={<Icon icon="sparkle" />}
b69ab31174 onClick={fetch}
b69ab31175 ref={ref}
b69ab31176 tooltip={
b69ab31177 <DetailsDropdown
b69ab31178 loadingState={loadingState}
b69ab31179 submit={fetch}
b69ab31180 guidanceToAI={guidanceToAI}
b69ab31181 setGuidanceToAI={setGuidanceToAI}
b69ab31182 />
b69ab31183 }
b69ab31184 />
b69ab31185 </Tooltip>
b69ab31186 );
b69ab31187 case 'LOADING':
b69ab31188 return (
b69ab31189 <Tooltip title={t('Split is working, click to cancel')} placement="bottom">
b69ab31190 <ButtonWithDropdownTooltip
b69ab31191 label={<T>Splitting</T>}
b69ab31192 data-testid="cwd-dropdown-button"
b69ab31193 kind="icon"
b69ab31194 icon={<Icon icon="loading" />}
b69ab31195 onClick={cancel}
b69ab31196 tooltip={
b69ab31197 <DetailsDropdown
b69ab31198 loadingState={loadingState}
b69ab31199 submit={cancel}
b69ab31200 guidanceToAI={guidanceToAI}
b69ab31201 setGuidanceToAI={setGuidanceToAI}
b69ab31202 />
b69ab31203 }
b69ab31204 />
b69ab31205 </Tooltip>
b69ab31206 );
b69ab31207 case 'ERROR':
b69ab31208 return (
b69ab31209 <Column alignStart>
b69ab31210 <ButtonWithDropdownTooltip
b69ab31211 label={<T>AI Split</T>}
b69ab31212 data-testid="cwd-dropdown-button"
b69ab31213 kind="icon"
b69ab31214 icon={<Icon icon="sparkle" />}
b69ab31215 onClick={fetch}
b69ab31216 tooltip={
b69ab31217 <DetailsDropdown
b69ab31218 loadingState={loadingState}
b69ab31219 submit={fetch}
b69ab31220 guidanceToAI={guidanceToAI}
b69ab31221 setGuidanceToAI={setGuidanceToAI}
b69ab31222 />
b69ab31223 }
b69ab31224 />
b69ab31225 <InlineErrorBadge error={loadingState.error} placement="bottom">
b69ab31226 <T>AI Split Failed</T>
b69ab31227 </InlineErrorBadge>
b69ab31228 </Column>
b69ab31229 );
b69ab31230 }
b69ab31231 },
b69ab31232);
b69ab31233
b69ab31234function useDiffWithoutGeneratedFiles(subStack: CommitStackState, rev: CommitRev): DiffCommit {
b69ab31235 const diffForAllFiles = diffCommit(subStack, rev);
b69ab31236 const allFilePaths = diffForAllFiles.files.map(f => f.bPath);
b69ab31237 const generatedFileStatuses = useGeneratedFileStatuses(allFilePaths);
b69ab31238 const filesWithoutGeneratedFiles = diffForAllFiles.files.filter(
b69ab31239 f => generatedFileStatuses[f.bPath] !== GeneratedStatus.Generated,
b69ab31240 );
b69ab31241 return {
b69ab31242 ...diffForAllFiles,
b69ab31243 files: filesWithoutGeneratedFiles,
b69ab31244 };
b69ab31245}
b69ab31246
b69ab31247function DetailsDropdown({
b69ab31248 loadingState,
b69ab31249 submit,
b69ab31250 guidanceToAI,
b69ab31251 setGuidanceToAI,
b69ab31252}: {
b69ab31253 loadingState: AISplitButtonLoadingState;
b69ab31254 submit: () => unknown;
b69ab31255 guidanceToAI: string;
b69ab31256 setGuidanceToAI: React.Dispatch<React.SetStateAction<string>>;
b69ab31257}) {
b69ab31258 const ref = useRef(null);
b69ab31259 return (
b69ab31260 <Column alignStart>
b69ab31261 <MinHeightTextField
b69ab31262 ref={ref}
b69ab31263 keepNewlines
b69ab31264 containerXstyle={styles.full}
b69ab31265 value={guidanceToAI}
b69ab31266 onInput={e => setGuidanceToAI(e.currentTarget.value)}>
b69ab31267 <T>Provide additional instructions to AI (optional)</T>
b69ab31268 </MinHeightTextField>
b69ab31269 <Button onClick={submit} style={{alignSelf: 'end'}} primary>
b69ab31270 {loadingState.type === 'LOADING' ? <Icon icon="loading" /> : <Icon icon="sparkle" />}
b69ab31271 {loadingState.type === 'LOADING' ? <T>Splitting</T> : <T>AI Split</T>}
b69ab31272 </Button>
b69ab31273 </Column>
b69ab31274 );
b69ab31275}