diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 13fbda2c4812..09fe26bf6dc4 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1257,6 +1257,7 @@ const CONST = { UNSHARE: 'UNSHARE', // OldDot Action UPDATE_GROUP_CHAT_MEMBER_ROLE: 'UPDATEGROUPCHATMEMBERROLE', CONCIERGE_CATEGORY_OPTIONS: 'CONCIERGECATEGORYOPTIONS', + CONCIERGE_DESCRIPTION_OPTIONS: 'CONCIERGEDESCRIPTIONOPTIONS', CONCIERGE_AUTO_MAP_MCC_GROUPS: 'CONCIERGEAUTOMAPMCCGROUPS', POLICY_CHANGE_LOG: { ADD_APPROVER_RULE: 'POLICYCHANGELOG_ADD_APPROVER_RULE', diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts index ae15f2bcc785..bbac5fead6af 100644 --- a/src/libs/ModifiedExpenseMessage.ts +++ b/src/libs/ModifiedExpenseMessage.ts @@ -241,11 +241,19 @@ function getForReportAction({ const hasModifiedComment = isReportActionOriginalMessageAnObject && 'oldComment' in reportActionOriginalMessage && 'newComment' in reportActionOriginalMessage; if (hasModifiedComment) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + let descriptionLabel = translateLocal('common.description'); + + // Add attribution suffix based on AI-generated descriptions + if (reportActionOriginalMessage?.aiGenerated) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + descriptionLabel += ` ${translateLocal('iou.basedOnAI')}`; + } + buildMessageFragmentForValue( Parser.htmlToMarkdown(reportActionOriginalMessage?.newComment ?? ''), Parser.htmlToMarkdown(reportActionOriginalMessage?.oldComment ?? ''), - // eslint-disable-next-line @typescript-eslint/no-deprecated - translateLocal('common.description'), + descriptionLabel, true, setFragments, removalFragments, @@ -398,6 +406,14 @@ function getForReportAction({ // eslint-disable-next-line @typescript-eslint/no-deprecated getMessageLine(`\n${translateLocal('iou.removed')}`, removalFragments); if (message === '') { + // If we don't have enough structured information to build a detailed message but we + // know the change was AI-generated, fall back to an AI-attributed generic summary so + // users can still understand that Concierge updated the expense automatically. + if (reportActionOriginalMessage?.aiGenerated) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return `${translateLocal('iou.changedTheExpense')} ${translateLocal('iou.basedOnAI')}`; + } + // eslint-disable-next-line @typescript-eslint/no-deprecated return translateLocal('iou.changedTheExpense'); } @@ -463,11 +479,19 @@ function getForReportActionTemp({ const hasModifiedComment = isReportActionOriginalMessageAnObject && 'oldComment' in reportActionOriginalMessage && 'newComment' in reportActionOriginalMessage; if (hasModifiedComment) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + let descriptionLabel = translateLocal('common.description'); + + // Add attribution suffix based on AI-generated descriptions + if (reportActionOriginalMessage?.aiGenerated) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + descriptionLabel += ` ${translateLocal('iou.basedOnAI')}`; + } + buildMessageFragmentForValue( Parser.htmlToMarkdown(reportActionOriginalMessage?.newComment ?? ''), Parser.htmlToMarkdown(reportActionOriginalMessage?.oldComment ?? ''), - // eslint-disable-next-line @typescript-eslint/no-deprecated - translateLocal('common.description'), + descriptionLabel, true, setFragments, removalFragments, diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index d9d78da74248..101a9e399b7d 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -871,6 +871,15 @@ function isResolvedConciergeCategoryOptions(reportAction: OnyxEntry): boolean { + const originalMessage = getOriginalMessage(reportAction); + const selectedDescription = originalMessage && typeof originalMessage === 'object' && 'selectedDescription' in originalMessage ? originalMessage?.selectedDescription : null; + return !!selectedDescription; +} + /** * Checks if a reportAction is fit for display, meaning that it's not deprecated, is of a valid * and supported type, it's not deleted and also not closed. @@ -1937,7 +1946,7 @@ function getReportActionMessageFragments(action: ReportAction): Message[] { return [{text: message, html: `${message}`, type: 'COMMENT'}]; } - if (isConciergeCategoryOptions(action)) { + if (isConciergeCategoryOptions(action) || isConciergeDescriptionOptions(action)) { const message = getReportActionMessageText(action); return [{text: message, html: message, type: 'COMMENT'}]; } @@ -2038,6 +2047,10 @@ function isConciergeCategoryOptions(reportAction: OnyxEntry): repo return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CONCIERGE_CATEGORY_OPTIONS); } +function isConciergeDescriptionOptions(reportAction: OnyxEntry): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CONCIERGE_DESCRIPTION_OPTIONS); +} + function getActionableJoinRequestPendingReportAction(reportID: string): OnyxEntry { const findPendingRequest = Object.values(getAllReportActions(reportID)).find((reportActionItem) => isActionableJoinRequestPendingReportAction(reportActionItem)); return findPendingRequest; @@ -3384,7 +3397,9 @@ export { isActionableTrackExpense, isExpenseChatWelcomeWhisper, isConciergeCategoryOptions, + isConciergeDescriptionOptions, isResolvedConciergeCategoryOptions, + isResolvedConciergeDescriptionOptions, isAddCommentAction, isApprovedOrSubmittedReportAction, isIOURequestReportAction, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 12484f9a5d6e..16484d7661d0 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -5977,35 +5977,79 @@ function changeReportPolicyAndInviteSubmitter( } /** - * Resolves Concierge category options by adding a comment and updating the report action + * Generic helper function to resolve Concierge AI-suggested options (category or description) * @param reportID - The report ID where the comment should be added and the report action should be updated - * @param notifyReportID - The report ID we should notify for new actions. This is usually the same as reportID, except when adding a comment to an expense report with a single transaction thread, in which case we want to notify the parent expense report. + * @param notifyReportID - The report ID we should notify for new actions * @param reportActionID - The specific report action ID to update - * @param selectedCategory - The category selected by the user + * @param selectedValue - The value selected by the user + * @param timezoneParam - The user's timezone + * @param selectedField - The field to update in the original message ('selectedCategory' or 'selectedDescription') + * @param ancestors - Array of ancestor reports for proper threading */ -function resolveConciergeCategoryOptions( +function resolveConciergeOptions( reportID: string | undefined, notifyReportID: string | undefined, reportActionID: string | undefined, - selectedCategory: string, + selectedValue: string, timezoneParam: Timezone, + selectedField: 'selectedCategory' | 'selectedDescription', ancestors: Ancestor[] = [], ) { if (!reportID || !reportActionID) { return; } - addComment(reportID, notifyReportID ?? reportID, ancestors, selectedCategory, timezoneParam); + addComment(reportID, notifyReportID ?? reportID, ancestors, selectedValue, timezoneParam); Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { [reportActionID]: { originalMessage: { - selectedCategory, + [selectedField]: selectedValue, }, }, } as Partial); } +/** + * Resolves Concierge category options by adding a comment and updating the report action + * @param reportID - The report ID where the comment should be added and the report action should be updated + * @param notifyReportID - The report ID we should notify for new actions. This is usually the same as reportID, except when adding a comment to an expense report with a single transaction thread, in which case we want to notify the parent expense report. + * @param reportActionID - The specific report action ID to update + * @param selectedCategory - The category selected by the user + * @param timezoneParam - The user's timezone + * @param ancestors - Array of ancestor reports for proper threading + */ +function resolveConciergeCategoryOptions( + reportID: string | undefined, + notifyReportID: string | undefined, + reportActionID: string | undefined, + selectedCategory: string, + timezoneParam: Timezone, + ancestors: Ancestor[] = [], +) { + resolveConciergeOptions(reportID, notifyReportID, reportActionID, selectedCategory, timezoneParam, 'selectedCategory', ancestors); +} + +/** + * Resolves Concierge description options by adding a comment and updating the report action + * @param reportID - The report ID where the comment should be added and the report action should be updated + * @param notifyReportID - The report ID we should notify for new actions. This is usually the same as reportID, except when adding a comment to an expense report with a single transaction thread, in which case we want to notify the parent expense report. + * @param reportActionID - The specific report action ID to update + * @param selectedDescription - The description selected by the user + * @param timezoneParam - The user's timezone + * @param ancestors - Array of ancestor reports for proper threading + */ +function resolveConciergeDescriptionOptions( + reportID: string | undefined, + notifyReportID: string | undefined, + reportActionID: string | undefined, + selectedDescription: string, + timezoneParam: Timezone, + ancestors: Ancestor[] = [], +) { + resolveConciergeOptions(reportID, notifyReportID, reportActionID, selectedDescription, timezoneParam, 'selectedDescription', ancestors); +} + /** * Enhances existing transaction thread reports with additional context for navigation * @@ -6104,6 +6148,7 @@ export { resolveActionableMentionConfirmWhisper, resolveActionableReportMentionWhisper, resolveConciergeCategoryOptions, + resolveConciergeDescriptionOptions, savePrivateNotesDraft, saveReportActionDraft, saveReportDraftComment, diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index 26f1de18ce52..8ce26eee75dd 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -118,6 +118,7 @@ import { isCardIssuedAction, isChronosOOOListAction, isConciergeCategoryOptions, + isConciergeDescriptionOptions, isCreatedTaskReportAction, isDeletedAction, isDeletedParentAction as isDeletedParentActionUtils, @@ -130,6 +131,7 @@ import { isReimbursementQueuedAction, isRenamedAction, isResolvedConciergeCategoryOptions, + isResolvedConciergeDescriptionOptions, isSplitBillAction as isSplitBillActionReportActionsUtils, isTagModificationAction, isTaskAction, @@ -171,7 +173,13 @@ import {openPersonalBankAccountSetupView} from '@userActions/BankAccounts'; import {resolveFraudAlert} from '@userActions/Card'; import {hideEmojiPicker, isActive} from '@userActions/EmojiPickerAction'; import {acceptJoinRequest, declineJoinRequest} from '@userActions/Policy/Member'; -import {createTransactionThreadReport, expandURLPreview, resolveActionableMentionConfirmWhisper, resolveConciergeCategoryOptions} from '@userActions/Report'; +import { + createTransactionThreadReport, + expandURLPreview, + resolveActionableMentionConfirmWhisper, + resolveConciergeCategoryOptions, + resolveConciergeDescriptionOptions, +} from '@userActions/Report'; import type {IgnoreDirection} from '@userActions/ReportActions'; import {isAnonymousUser, signOutAndRedirectToSignIn} from '@userActions/Session'; import {isBlockedFromConcierge} from '@userActions/User'; @@ -776,6 +784,29 @@ function PureReportActionItem({ })); } + if (isConciergeDescriptionOptions(action)) { + const options = getOriginalMessage(action)?.options; + if (!options) { + return []; + } + + if (isResolvedConciergeDescriptionOptions(action)) { + return []; + } + + if (!reportActionReportID) { + return []; + } + + return options.map((option, i) => ({ + text: `${i + 1} - ${option}`, + key: `${action.reportActionID}-conciergeDescriptionOptions-${option}`, + onPress: () => { + resolveConciergeDescriptionOptions(reportActionReportID, reportID, action.reportActionID, option, personalDetail.timezone ?? CONST.DEFAULT_TIME_ZONE); + }, + })); + } + if (!isActionableWhisper && !isActionableCardFraudAlert(action) && (!isActionableJoinRequest(action) || getOriginalMessage(action)?.choice !== ('' as JoinWorkspaceResolution))) { return []; } @@ -1464,8 +1495,15 @@ function PureReportActionItem({ {actionableItemButtons.length > 0 && ( )} diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index 7485e78b3e9a..2bcae3bc4c7f 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -666,7 +666,10 @@ type OriginalMessageModifiedExpense = { newAttendees?: Attendee[]; /** Source of category change (agentZero, mccMapping, or manual) */ - source?: string; + source?: ValueOf; + + /** Whether the updated description was generated by AI */ + aiGenerated?: boolean; }; /** Model of a `travel update` report action */ @@ -690,9 +693,9 @@ type OriginalMessageDeletedTransaction = { currency?: string; }; -/** Model of `concierge category options` report action */ -type OriginalMessageConciergeCategoryOptions = { - /** The options we present to the user when confidence in the predicted category is low */ +/** Common model for Concierge category/description options actions */ +type OriginalMessageConciergeBaseOptions = { + /** The options we present to the user when confidence in the prediction is low */ options: string[]; /** The confidence levels for each option */ @@ -700,7 +703,10 @@ type OriginalMessageConciergeCategoryOptions = { /** The transaction ID associated with this action */ transactionID?: string; +}; +/** Model of `concierge category options` report action */ +type OriginalMessageConciergeCategoryOptions = OriginalMessageConciergeBaseOptions & { /** The category selected by the user (set when the action is resolved) */ selectedCategory?: string; @@ -708,6 +714,15 @@ type OriginalMessageConciergeCategoryOptions = { agentZero?: Record; }; +/** Model of `concierge description options` report action */ +type OriginalMessageConciergeDescriptionOptions = OriginalMessageConciergeBaseOptions & { + /** The description selected by the user (set when the action is resolved) */ + selectedDescription?: string; + + /** Agent Zero metadata (optional) */ + agentZero?: Record; +}; + /** Model of `concierge auto map mcc groups` report action */ type OriginalMessageConciergeAutoMapMccGroups = { /** The policy ID for which MCC groups were auto-mapped */ @@ -1089,6 +1104,7 @@ type OriginalMessageMap = { [CONST.REPORT.ACTIONS.TYPE.INTEGRATION_SYNC_FAILED]: OriginalMessageIntegrationSyncFailed; [CONST.REPORT.ACTIONS.TYPE.DELETED_TRANSACTION]: OriginalMessageDeletedTransaction; [CONST.REPORT.ACTIONS.TYPE.CONCIERGE_CATEGORY_OPTIONS]: OriginalMessageConciergeCategoryOptions; + [CONST.REPORT.ACTIONS.TYPE.CONCIERGE_DESCRIPTION_OPTIONS]: OriginalMessageConciergeDescriptionOptions; [CONST.REPORT.ACTIONS.TYPE.CONCIERGE_AUTO_MAP_MCC_GROUPS]: OriginalMessageConciergeAutoMapMccGroups; [CONST.REPORT.ACTIONS.TYPE.RETRACTED]: never; [CONST.REPORT.ACTIONS.TYPE.REOPENED]: never; diff --git a/tests/unit/ModifiedExpenseMessageTest.ts b/tests/unit/ModifiedExpenseMessageTest.ts index 24fd4c248cb7..5e8551678fb6 100644 --- a/tests/unit/ModifiedExpenseMessageTest.ts +++ b/tests/unit/ModifiedExpenseMessageTest.ts @@ -4,6 +4,7 @@ import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import {translate} from '@src/libs/Localize'; +import type {OriginalMessageModifiedExpense} from '@src/types/onyx/OriginalMessage'; import createRandomReportAction from '../utils/collections/reportActions'; import {createRandomReport} from '../utils/collections/reports'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -608,6 +609,46 @@ describe('ModifiedExpenseMessage', () => { }); }); + describe('when the description is set with AI attribution', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + originalMessage: { + oldComment: '', + newComment: 'Flight to client meeting', + aiGenerated: true, + }, + }; + + it('returns the correct text message with AI attribution when setting description', () => { + const expectedResult = 'set the description based on past activity to "Flight to client meeting"'; + + const result = getForReportAction({reportAction, policyID: report.policyID}); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when the description is changed with AI attribution', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + originalMessage: { + oldComment: 'Old description', + newComment: 'New description', + aiGenerated: true, + }, + }; + + it('returns the correct text message with AI attribution when changing description', () => { + const expectedResult = 'changed the description based on past activity to "New description" (previously "Old description")'; + + const result = getForReportAction({reportAction, policyID: report.policyID}); + + expect(result).toEqual(expectedResult); + }); + }); + describe('when the category is changed with AI attribution', () => { const reportAction = { ...createRandomReportAction(1), @@ -615,8 +656,8 @@ describe('ModifiedExpenseMessage', () => { originalMessage: { category: 'Travel', oldCategory: 'Food', - source: 'agentZero', - }, + source: CONST.CATEGORY_SOURCE.AI, + } as OriginalMessageModifiedExpense, }; it('returns the correct text message with AI attribution', () => { @@ -635,8 +676,8 @@ describe('ModifiedExpenseMessage', () => { originalMessage: { category: 'Travel', oldCategory: 'Food', - source: 'mccMapping', - }, + source: CONST.CATEGORY_SOURCE.MCC, + } as OriginalMessageModifiedExpense, }; it('returns the correct text message with MCC attribution', () => { @@ -655,8 +696,8 @@ describe('ModifiedExpenseMessage', () => { originalMessage: { category: 'Travel', oldCategory: '', - source: 'agentZero', - }, + source: CONST.CATEGORY_SOURCE.AI, + } as OriginalMessageModifiedExpense, }; it('returns the correct text message with AI attribution', () => { @@ -675,8 +716,8 @@ describe('ModifiedExpenseMessage', () => { originalMessage: { category: '', oldCategory: 'Travel', - source: 'agentZero', - }, + source: CONST.CATEGORY_SOURCE.AI, + } as OriginalMessageModifiedExpense, }; it('returns the correct text message with AI attribution', () => { @@ -695,7 +736,7 @@ describe('ModifiedExpenseMessage', () => { originalMessage: { category: 'Travel', oldCategory: 'Food', - }, + } as OriginalMessageModifiedExpense, }; it('returns the correct text message without attribution', () => { @@ -714,8 +755,8 @@ describe('ModifiedExpenseMessage', () => { originalMessage: { category: 'Travel', oldCategory: 'Food', - source: 'manual', - }, + source: CONST.CATEGORY_SOURCE.MANUAL, + } as OriginalMessageModifiedExpense, }; it('returns the correct text message without attribution', () => {