From d89e196e2e17f61db80c11e4c7e592dea79bc91e Mon Sep 17 00:00:00 2001 From: GCyganek Date: Wed, 26 Aug 2026 11:32:18 +0200 Subject: [PATCH 1/7] Fix Home - Task is not created in Concierge after sending [] task from Concierge prompt box --- .../Search/SearchRouter/useAskConcierge.tsx | 11 ++- src/libs/actions/Task.ts | 87 +++++++++++++++++++ .../ReportActionCompose/useComposerSubmit.ts | 66 +------------- tests/ui/ReportActionComposeTest.tsx | 28 +++--- 4 files changed, 111 insertions(+), 81 deletions(-) diff --git a/src/components/Search/SearchRouter/useAskConcierge.tsx b/src/components/Search/SearchRouter/useAskConcierge.tsx index bf04a6447238..68195d3ed9d2 100644 --- a/src/components/Search/SearchRouter/useAskConcierge.tsx +++ b/src/components/Search/SearchRouter/useAskConcierge.tsx @@ -7,6 +7,7 @@ import useSidePanelReportID from '@hooks/useSidePanelReportID'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {addAttachmentWithComment, addComment} from '@userActions/Report'; +import {createTaskFromMarkdown} from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -25,7 +26,9 @@ function useAskConcierge({forceConcierge = false}: {forceConcierge?: boolean} = const {openConciergeAnywhere, isInSidePanel} = useOpenConciergeAnywhere(); const targetReportID = !forceConcierge && isInSidePanel && sidePanelReportID ? sidePanelReportID : conciergeReportID; const [targetReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(targetReportID)}`); - const {timezone, accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const {timezone, accountID: currentUserAccountID} = currentUserPersonalDetails; + const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); const delegateAccountID = useDelegateAccountID(); const shouldShowAskConcierge = !!targetReportID && !!targetReport; @@ -35,6 +38,12 @@ function useAskConcierge({forceConcierge = false}: {forceConcierge?: boolean} = return; } openConciergeAnywhere({forceConcierge}); + + // The `[] task` shorthand has to be handled here too, otherwise it would be sent to Concierge as a plain + // message instead of creating a task, unlike the same text typed into the report composer. + if (createTaskFromMarkdown({text: trimmedQuery, parentReport: targetReport, currentUserPersonalDetails, quickAction})) { + return; + } addComment({ report: targetReport, notifyReportID: targetReportID, diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 4e420b45ad3c..3ba048610ec0 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -7,18 +7,22 @@ import type {CancelTaskParams, CompleteTaskParams, CreateTaskParams, EditTaskAss import {WRITE_COMMANDS} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; +import {isEmailPublicDomain} from '@libs/LoginUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import Navigation from '@libs/Navigation/Navigation'; import {getDBTimeWithSkew} from '@libs/NetworkState'; import * as OptionsListUtils from '@libs/OptionsListUtils'; +import {addDomainToShortMention} from '@libs/ParsingUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import {deprecatedGetReportName} from '@libs/ReportNameUtils'; import * as ReportUtils from '@libs/ReportUtils'; import {buildOptimisticSnapshotData} from '@libs/SearchQueryUtils'; +import {getAllPersonalDetailLogins} from '@libs/ShortMentionLogins'; import playSound, {SOUNDS} from '@libs/Sound'; import type {AvatarSource} from '@libs/UserAvatarUtils'; +import {generateAccountID} from '@libs/UserUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -27,6 +31,7 @@ import type {Route} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {Icon} from '@src/types/onyx/OnyxCommon'; import type PersonalDetails from '@src/types/onyx/PersonalDetails'; +import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type {ReportActions} from '@src/types/onyx/ReportAction'; import type ReportAction from '@src/types/onyx/ReportAction'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -35,6 +40,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {NullishDeep, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import {Str} from 'expensify-common'; import Onyx from 'react-native-onyx'; import {getMostRecentReportID, navigateToConciergeChatAndDeleteReport, notifyNewAction, optimisticReportLastData} from './Report'; @@ -87,6 +93,15 @@ type CreateTaskAndNavigateParams = { taskCreatorAndAssigneeDetails: OnyxEntry; }; +type CreateTaskFromMarkdownParams = { + /** The already trimmed text the user is sending */ + text: string; + parentReport: OnyxEntry; + currentUserPersonalDetails: CurrentUserPersonalDetails; + quickAction: OnyxEntry; + ancestors?: ReportUtils.Ancestor[]; +}; + type DeleteTaskOptions = { ancestors?: ReportUtils.Ancestor[]; shouldNavigateBack?: boolean; @@ -410,6 +425,77 @@ function createTaskAndNavigate(params: CreateTaskAndNavigateParams) { notifyNewAction(parentReportID, optimisticAddCommentReport.reportAction, true); } +/** + * Creates a task from the `[] title` markdown shorthand (with an optional `@mention` assignee). + * + * @returns true when a task was created, so the caller can skip sending the text as a plain comment. + */ +function createTaskFromMarkdown({text, parentReport, currentUserPersonalDetails, quickAction, ancestors = []}: CreateTaskFromMarkdownParams): boolean { + // A task cannot be created without a parent report, so let the caller fall back to sending the text as a comment. + if (!parentReport?.reportID) { + return false; + } + + const taskMatch = text.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); + if (!taskMatch) { + return false; + } + + let taskTitle = taskMatch[3] ? taskMatch[3].trim().replaceAll('\n', ' ') : undefined; + if (!taskTitle) { + return false; + } + + const currentUserEmail = currentUserPersonalDetails.email ?? ''; + const mention = taskMatch[1] ? taskMatch[1].trim() : ''; + const currentUserPrivateDomain = isEmailPublicDomain(currentUserEmail) ? '' : Str.extractEmailDomain(currentUserEmail); + const mentionWithDomain = addDomainToShortMention(mention, getAllPersonalDetailLogins(), currentUserPrivateDomain) ?? mention; + const isValidMention = Str.isValidEmail(mentionWithDomain); + + let assignee: OnyxEntry; + let assigneeChatReport; + if (mentionWithDomain) { + if (isValidMention) { + assignee = PersonalDetailsUtils.getPersonalDetailByEmail(mentionWithDomain); + if (!assignee) { + const optimisticDataForNewAssignee = setNewOptimisticAssignee(currentUserPersonalDetails.accountID, { + accountID: generateAccountID(mentionWithDomain), + login: mentionWithDomain, + }); + assignee = optimisticDataForNewAssignee.assignee; + assigneeChatReport = optimisticDataForNewAssignee.assigneeReport; + } + } else { + taskTitle = `@${mentionWithDomain} ${taskTitle}`; + } + } + + const taskCreatorAndAssigneeDetails = {[currentUserPersonalDetails.accountID]: currentUserPersonalDetails}; + if (assignee) { + taskCreatorAndAssigneeDetails[assignee.accountID] = assignee; + } + + createTaskAndNavigate({ + parentReport, + title: taskTitle, + description: '', + assigneeEmail: assignee?.login ?? '', + currentUserAccountID: currentUserPersonalDetails.accountID, + currentUserEmail, + currentUserDisplayName: currentUserPersonalDetails.displayName, + currentUserAvatar: currentUserPersonalDetails.avatar, + assigneeAccountID: assignee?.accountID, + assigneeChatReport, + policyID: parentReport?.policyID, + isCreatedUsingMarkdown: true, + quickAction, + ancestors, + taskCreatorAndAssigneeDetails, + }); + + return true; +} + function buildTaskData( taskReport: OnyxEntry, taskReportID: string, @@ -1554,6 +1640,7 @@ function completeTestDriveTask( export { createTaskAndNavigate, + createTaskFromMarkdown, editTask, editTaskAssignee, setTitleValue, diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts index b3c2c6533a5d..7fcedf1af829 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts @@ -7,18 +7,13 @@ import usePermissions from '@hooks/usePermissions'; import useReportIsArchived from '@hooks/useReportIsArchived'; import {addAttachmentWithComment, addComment, clearAgentZeroProcessingIndicator} from '@libs/actions/Report'; -import {createTaskAndNavigate, setNewOptimisticAssignee} from '@libs/actions/Task'; -import {isEmailPublicDomain} from '@libs/LoginUtils'; +import {createTaskFromMarkdown} from '@libs/actions/Task'; import {rand64} from '@libs/NumberUtils'; -import {addDomainToShortMention} from '@libs/ParsingUtils'; -import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {getAllReportActions} from '@libs/ReportActionsUtils'; import {canUserPerformWriteAction, generateReportID, isConciergeChatReport} from '@libs/ReportUtils'; -import {getAllPersonalDetailLogins} from '@libs/ShortMentionLogins'; import {startSpan} from '@libs/telemetry/activeSpans'; import getSendMessageListWeight from '@libs/telemetry/getSendMessageListWeight'; import getSendMessageSource from '@libs/telemetry/getSendMessageSource'; -import {generateAccountID} from '@libs/UserUtils'; import {useActionListContext} from '@pages/inbox/ActionListContext'; @@ -26,12 +21,8 @@ import {setIsComposerFullSize} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type * as OnyxTypes from '@src/types/onyx'; - -import type {OnyxEntry} from 'react-native-onyx'; import {useRoute} from '@react-navigation/native'; -import {Str} from 'expensify-common'; import {useComposerActions, useComposerEditActions, useComposerEditState, useComposerMeta, useComposerSendState} from './ComposerContext'; import useComposerReportData from './useComposerReportData'; @@ -62,8 +53,6 @@ function useComposerSubmit(reportID: string) { const reportAncestors = useAncestors(report); const targetReportAncestors = useAncestors(targetReport); - const currentUserEmail = currentUserPersonalDetails.email ?? ''; - /** * Add or edit a comment in the composer */ @@ -107,57 +96,8 @@ function useComposerSubmit(reportID: string) { return; } - const taskMatch = draftMessageTrimmed.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); - if (taskMatch) { - let taskTitle = taskMatch[3] ? taskMatch[3].trim().replaceAll('\n', ' ') : undefined; - if (taskTitle) { - const mention = taskMatch[1] ? taskMatch[1].trim() : ''; - const currentUserPrivateDomain = isEmailPublicDomain(currentUserEmail) ? '' : Str.extractEmailDomain(currentUserEmail); - const mentionWithDomain = addDomainToShortMention(mention, getAllPersonalDetailLogins(), currentUserPrivateDomain) ?? mention; - const isValidMention = Str.isValidEmail(mentionWithDomain); - - let assignee: OnyxEntry; - let assigneeChatReport; - if (mentionWithDomain) { - if (isValidMention) { - assignee = getPersonalDetailByEmail(mentionWithDomain); - if (!assignee) { - const optimisticDataForNewAssignee = setNewOptimisticAssignee(currentUserPersonalDetails.accountID, { - accountID: generateAccountID(mentionWithDomain), - login: mentionWithDomain, - }); - assignee = optimisticDataForNewAssignee.assignee; - assigneeChatReport = optimisticDataForNewAssignee.assigneeReport; - } - } else { - taskTitle = `@${mentionWithDomain} ${taskTitle}`; - } - } - - const taskCreatorAndAssigneeDetails = {[currentUserPersonalDetails.accountID]: currentUserPersonalDetails}; - if (assignee) { - taskCreatorAndAssigneeDetails[assignee.accountID] = assignee; - } - - createTaskAndNavigate({ - parentReport: report, - title: taskTitle, - description: '', - assigneeEmail: assignee?.login ?? '', - currentUserAccountID: currentUserPersonalDetails.accountID, - currentUserEmail, - currentUserDisplayName: currentUserPersonalDetails.displayName, - currentUserAvatar: currentUserPersonalDetails.avatar, - assigneeAccountID: assignee?.accountID, - assigneeChatReport, - policyID: report?.policyID, - isCreatedUsingMarkdown: true, - quickAction, - ancestors: reportAncestors, - taskCreatorAndAssigneeDetails, - }); - return; - } + if (createTaskFromMarkdown({text: draftMessageTrimmed, parentReport: report, currentUserPersonalDetails, quickAction, ancestors: reportAncestors})) { + return; } const optimisticReportActionID = rand64(); diff --git a/tests/ui/ReportActionComposeTest.tsx b/tests/ui/ReportActionComposeTest.tsx index 465424818175..97a7c64be668 100644 --- a/tests/ui/ReportActionComposeTest.tsx +++ b/tests/ui/ReportActionComposeTest.tsx @@ -7,7 +7,7 @@ import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {KeyboardStateProvider} from '@components/withKeyboardState'; import type * as TaskActions from '@libs/actions/Task'; -import {createTaskAndNavigate} from '@libs/actions/Task'; +import {createTaskFromMarkdown} from '@libs/actions/Task'; import type {ReportActionComposeProps} from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; import ReportActionCompose from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; @@ -33,7 +33,7 @@ jest.mock('@libs/ComponentUtils', () => ({ jest.mock('@libs/actions/Task', () => ({ ...jest.requireActual('@libs/actions/Task'), - createTaskAndNavigate: jest.fn(), + createTaskFromMarkdown: jest.fn(() => true), })); jest.mock('@hooks/useLocalize', () => @@ -520,33 +520,27 @@ describe('ReportActionCompose Integration Tests', () => { }); }); - describe('Task creation with a short mention', () => { - it('assigns the task to the user resolved from a same-private-domain short mention', async () => { - // Given a current user on a private domain and a coworker on the same domain - const coworkerAccountID = 2; + describe('Task creation', () => { + // The `[] title` parsing itself (short mentions included) is covered in tests/actions/TaskTest.ts; what matters + // here is that the composer routes the draft through the shared detection instead of sending it as a comment. + it('hands a `[] task` draft to the shared markdown task detection', async () => { await TestHelper.signInWithTestUser(1, 'user@domain.com'); - await act(async () => { - await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { - [coworkerAccountID]: TestHelper.buildPersonalDetails('mat@domain.com', coworkerAccountID, 'Mat'), - }); - }); const {unmount} = renderReportActionCompose(); await waitForBatchedUpdatesWithAct(); - // When a task with a short mention of the coworker is typed and submitted + // When a task with a short mention of a coworker is typed and submitted // (the composer submits by clearing the input, which hands the draft to validateAndSubmitDraft) const composer = screen.getByTestId('composer'); fireEvent.changeText(composer, '[] @mat Buy milk'); fireEvent(composer, 'clear', {nativeEvent: {text: '[] @mat Buy milk'}}); - // Then the task is created with the mention resolved to the coworker's full login + // Then the draft is passed to the task detection along with the report it should be created in await waitFor(() => { - expect(createTaskAndNavigate).toHaveBeenCalledWith( + expect(createTaskFromMarkdown).toHaveBeenCalledWith( expect.objectContaining({ - title: 'Buy milk', - assigneeEmail: 'mat@domain.com', - assigneeAccountID: coworkerAccountID, + text: '[] @mat Buy milk', + parentReport: expect.objectContaining({reportID: REPORT_ID}), }), ); }); From da925b81cab466135d690d155588f0481a14c87a Mon Sep 17 00:00:00 2001 From: GCyganek Date: Wed, 26 Aug 2026 11:42:42 +0200 Subject: [PATCH 2/7] Fix and add tests --- tests/actions/TaskTest.ts | 117 ++++++++++++++++++++++ tests/ui/ReportActionComposeTest.tsx | 2 +- tests/unit/hooks/useAskConcierge.test.tsx | 39 +++++++- 3 files changed, 156 insertions(+), 2 deletions(-) diff --git a/tests/actions/TaskTest.ts b/tests/actions/TaskTest.ts index 1e78844dbbf9..ba007a82bc2e 100644 --- a/tests/actions/TaskTest.ts +++ b/tests/actions/TaskTest.ts @@ -13,6 +13,7 @@ import { completeTask, completeTestDriveTask, createTaskAndNavigate, + createTaskFromMarkdown, deleteTask, editTask, editTaskAssignee, @@ -22,6 +23,7 @@ import { getShareDestination, } from '@libs/actions/Task'; import * as API from '@libs/API'; +import {WRITE_COMMANDS} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getReportName} from '@libs/ReportNameUtils'; @@ -33,6 +35,7 @@ import CONST from '@src/CONST'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetailsList, Policy, Report, ReportAction} from '@src/types/onyx'; +import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type {OnyxData} from '@src/types/onyx/Request'; import type {OnyxEntry, OnyxKey, OnyxUpdate} from 'react-native-onyx'; @@ -998,6 +1001,120 @@ describe('actions/Task', () => { }); }); + describe('createTaskFromMarkdown', () => { + const parentReportID = 'markdown_parent_report'; + const currentUserAccountID = 1; + const coworkerAccountID = 2; + const currentUserPersonalDetails = { + accountID: currentUserAccountID, + login: 'user@domain.com', + email: 'user@domain.com', + displayName: 'User', + avatar: 'https://example.com/avatar.png', + } as CurrentUserPersonalDetails; + const parentReport = {reportID: parentReportID, type: CONST.REPORT.TYPE.CHAT} as Report; + + beforeEach(async () => { + jest.clearAllMocks(); + writeSpy.mockClear(); + global.fetch = getGlobalFetchMock(); + + mockBuildOptimisticTaskReport.mockImplementation((...args: unknown[]) => ({ + reportID: 'markdown_task_report', + reportName: args.at(3), + type: CONST.REPORT.TYPE.TASK, + parentReportID, + })); + mockBuildOptimisticCreatedReportAction.mockReturnValue({ + reportActionID: 'created_action_1', + reportAction: {reportActionID: 'created_action_1', actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, created: DateUtils.getDBTime()}, + }); + mockBuildOptimisticTaskCommentReportAction.mockReturnValue({ + reportAction: {reportActionID: 'comment_action_1', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: DateUtils.getDBTime()}, + }); + mockGetTaskAssigneeChatOnyxData.mockReturnValue({optimisticData: [], successData: [], failureData: []}); + mockIsHiddenForCurrentUser.mockReturnValue(false); + mockFormatReportLastMessageText.mockReturnValue('Last message text'); + + await act(async () => { + await Onyx.clear(); + await Onyx.set(ONYXKEYS.SESSION, {email: currentUserPersonalDetails.login, accountID: currentUserAccountID}); + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [currentUserAccountID]: {accountID: currentUserAccountID, login: 'user@domain.com', displayName: 'User'}, + [coworkerAccountID]: {accountID: coworkerAccountID, login: 'mat@domain.com', displayName: 'Mat'}, + } as PersonalDetailsList); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, parentReport); + }); + await waitForBatchedUpdatesWithAct(); + }); + + it('does not create a task from text that is not the markdown shorthand', () => { + expect(createTaskFromMarkdown({text: 'Buy milk', parentReport, currentUserPersonalDetails, quickAction: undefined})).toBe(false); + expect(writeSpy).not.toHaveBeenCalled(); + }); + + it('does not create a task when the shorthand has no title', () => { + expect(createTaskFromMarkdown({text: '[]', parentReport, currentUserPersonalDetails, quickAction: undefined})).toBe(false); + expect(createTaskFromMarkdown({text: '[] ', parentReport, currentUserPersonalDetails, quickAction: undefined})).toBe(false); + expect(writeSpy).not.toHaveBeenCalled(); + }); + + it('does not create a task when there is no parent report', () => { + expect(createTaskFromMarkdown({text: '[] Buy milk', parentReport: undefined, currentUserPersonalDetails, quickAction: undefined})).toBe(false); + expect(writeSpy).not.toHaveBeenCalled(); + }); + + it('creates an unassigned task from the shorthand', () => { + // When the shorthand is sent without a mention + expect(createTaskFromMarkdown({text: '[] Buy milk', parentReport, currentUserPersonalDetails, quickAction: undefined})).toBe(true); + + // Then the task is created with the remaining text as its title and nobody assigned + expect(mockBuildOptimisticTaskReport).toHaveBeenCalledWith(currentUserAccountID, parentReportID, 0, 'Buy milk', '', CONST.POLICY.OWNER_EMAIL_FAKE, expect.anything(), undefined); + expect(writeSpy).toHaveBeenCalledWith(WRITE_COMMANDS.CREATE_TASK, expect.objectContaining({assignee: '', assigneeAccountID: 0}), expect.anything()); + }); + + it('assigns the task to the user resolved from a same-private-domain short mention', () => { + // When the shorthand mentions a coworker by their short login + expect(createTaskFromMarkdown({text: '[] @mat Buy milk', parentReport, currentUserPersonalDetails, quickAction: undefined})).toBe(true); + + // Then the mention is resolved to the full login and dropped from the title + expect(mockBuildOptimisticTaskReport).toHaveBeenCalledWith( + currentUserAccountID, + parentReportID, + coworkerAccountID, + 'Buy milk', + '', + CONST.POLICY.OWNER_EMAIL_FAKE, + expect.anything(), + undefined, + ); + expect(writeSpy).toHaveBeenCalledWith(WRITE_COMMANDS.CREATE_TASK, expect.objectContaining({assignee: 'mat@domain.com', assigneeAccountID: coworkerAccountID}), expect.anything()); + }); + + it('keeps an unresolvable mention in the title instead of assigning it', () => { + // When the mention cannot be resolved to a known login + expect(createTaskFromMarkdown({text: '[] @nobody Buy milk', parentReport, currentUserPersonalDetails, quickAction: undefined})).toBe(true); + + // Then it stays part of the title and the task is left unassigned + expect(mockBuildOptimisticTaskReport).toHaveBeenCalledWith( + currentUserAccountID, + parentReportID, + 0, + '@nobody Buy milk', + '', + CONST.POLICY.OWNER_EMAIL_FAKE, + expect.anything(), + undefined, + ); + }); + + it('does not navigate, so it is safe to call from outside a modal flow', () => { + createTaskFromMarkdown({text: '[] Buy milk', parentReport, currentUserPersonalDetails, quickAction: undefined}); + + expect(Navigation.dismissModalWithReport).not.toHaveBeenCalled(); + }); + }); + describe('completeTask', () => { const mockTaskReportID = 'task_report_456'; const mockParentReportID = 'parent_report_789'; diff --git a/tests/ui/ReportActionComposeTest.tsx b/tests/ui/ReportActionComposeTest.tsx index 97a7c64be668..e216cb8abf77 100644 --- a/tests/ui/ReportActionComposeTest.tsx +++ b/tests/ui/ReportActionComposeTest.tsx @@ -540,7 +540,7 @@ describe('ReportActionCompose Integration Tests', () => { expect(createTaskFromMarkdown).toHaveBeenCalledWith( expect.objectContaining({ text: '[] @mat Buy milk', - parentReport: expect.objectContaining({reportID: REPORT_ID}), + parentReport: expect.objectContaining({reportID: defaultReport.reportID}), }), ); }); diff --git a/tests/unit/hooks/useAskConcierge.test.tsx b/tests/unit/hooks/useAskConcierge.test.tsx index 068144db50d1..2ee603cf82f5 100644 --- a/tests/unit/hooks/useAskConcierge.test.tsx +++ b/tests/unit/hooks/useAskConcierge.test.tsx @@ -3,6 +3,7 @@ import {renderHook} from '@testing-library/react-native'; import useAskConcierge from '@components/Search/SearchRouter/useAskConcierge'; import {addAttachmentWithComment, addComment} from '@userActions/Report'; +import {createTaskFromMarkdown} from '@userActions/Task'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report} from '@src/types/onyx'; @@ -32,7 +33,7 @@ jest.mock('@hooks/useOpenConciergeAnywhere', () => ({ jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ __esModule: true, - default: () => ({accountID: 1, timezone: {selected: 'Europe/Warsaw'}}), + default: () => ({accountID: 1, timezone: {selected: 'Europe/Warsaw'}, login: 'user@domain.com', email: 'user@domain.com', displayName: 'User', avatar: undefined}), })); jest.mock('@hooks/useDelegateAccountID', () => ({ @@ -45,8 +46,13 @@ jest.mock('@userActions/Report', () => ({ addAttachmentWithComment: jest.fn(), })); +jest.mock('@userActions/Task', () => ({ + createTaskFromMarkdown: jest.fn(() => false), +})); + const mockAddComment = jest.mocked(addComment); const mockAddAttachmentWithComment = jest.mocked(addAttachmentWithComment); +const mockCreateTaskFromMarkdown = jest.mocked(createTaskFromMarkdown); const CONCIERGE_REPORT = {reportID: CONCIERGE_REPORT_ID, reportName: 'Concierge'} as Report; const ADMINS_ROOM_REPORT = {reportID: ADMINS_ROOM_REPORT_ID, reportName: '#admins'} as Report; @@ -138,6 +144,37 @@ describe('useAskConcierge', () => { ); }); + it('creates a task instead of a comment when the message uses the `[] task` shorthand', async () => { + // Given a loaded Concierge report and text that the markdown task detection claims + await seedReports(); + mockCreateTaskFromMarkdown.mockReturnValueOnce(true); + const {result} = renderAskConcierge({forceConcierge: true}); + expect(result.current.shouldShowAskConcierge).toBe(true); + + // When the shorthand is sent + result.current.askConcierge(' [] Buy milk '); + + // Then Concierge is still opened, the detection gets the trimmed text and the target report, + // and the text is not also posted as a plain comment + expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true}); + expect(mockCreateTaskFromMarkdown).toHaveBeenCalledWith(expect.objectContaining({text: '[] Buy milk', parentReport: CONCIERGE_REPORT})); + expect(mockAddComment).not.toHaveBeenCalled(); + }); + + it('falls back to a comment when the text is not the task shorthand', async () => { + // Given a loaded Concierge report and text the markdown task detection does not claim + await seedReports(); + const {result} = renderAskConcierge({forceConcierge: true}); + expect(result.current.shouldShowAskConcierge).toBe(true); + + // When a plain message is sent + result.current.askConcierge('Where is my expense?'); + + // Then it is sent as a comment + expect(mockCreateTaskFromMarkdown).toHaveBeenCalled(); + expect(mockAddComment).toHaveBeenCalledWith(expect.objectContaining({text: 'Where is my expense?'})); + }); + it('does nothing for a whitespace-only message', async () => { // Given a loaded Concierge report await seedReports(); From afdb57eb33e0881e28d1d6c009f874cdfffe75a3 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Wed, 26 Aug 2026 11:44:32 +0200 Subject: [PATCH 3/7] Comments --- src/components/Search/SearchRouter/useAskConcierge.tsx | 2 -- src/libs/actions/Task.ts | 4 ++++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/Search/SearchRouter/useAskConcierge.tsx b/src/components/Search/SearchRouter/useAskConcierge.tsx index 68195d3ed9d2..a5396918f64f 100644 --- a/src/components/Search/SearchRouter/useAskConcierge.tsx +++ b/src/components/Search/SearchRouter/useAskConcierge.tsx @@ -39,8 +39,6 @@ function useAskConcierge({forceConcierge = false}: {forceConcierge?: boolean} = } openConciergeAnywhere({forceConcierge}); - // The `[] task` shorthand has to be handled here too, otherwise it would be sent to Concierge as a plain - // message instead of creating a task, unlike the same text typed into the report composer. if (createTaskFromMarkdown({text: trimmedQuery, parentReport: targetReport, currentUserPersonalDetails, quickAction})) { return; } diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 3ba048610ec0..9bbcd83869d2 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -96,9 +96,13 @@ type CreateTaskAndNavigateParams = { type CreateTaskFromMarkdownParams = { /** The already trimmed text the user is sending */ text: string; + /** The parent report to which the task belongs */ parentReport: OnyxEntry; + /** The current user's personal details */ currentUserPersonalDetails: CurrentUserPersonalDetails; + /** The quick action associated with the task */ quickAction: OnyxEntry; + /** The ancestors of the task */ ancestors?: ReportUtils.Ancestor[]; }; From 7830e1024f774cdb010fca66ba8c85bc68eb7647 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Wed, 26 Aug 2026 11:46:48 +0200 Subject: [PATCH 4/7] Fix knip check --- src/libs/actions/Task.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 9bbcd83869d2..2107619d7c21 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -1665,7 +1665,6 @@ export { getTaskAssigneeAccountID, clearTaskErrors, canModifyTask, - setNewOptimisticAssignee, getNavigationUrlOnTaskDelete, canActionTask, getFinishOnboardingTaskOnyxData, From 080f9c81137e77e74101b8df006ed38079b9faa7 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Wed, 26 Aug 2026 12:56:36 +0200 Subject: [PATCH 5/7] Fix comment max length check --- src/libs/actions/Task.ts | 2 +- .../home/ForYouSection/ConciergePromptBox.tsx | 249 ++++++++++-------- tests/actions/TaskTest.ts | 18 ++ 3 files changed, 157 insertions(+), 112 deletions(-) diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 2107619d7c21..1b457553c64c 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -446,7 +446,7 @@ function createTaskFromMarkdown({text, parentReport, currentUserPersonalDetails, } let taskTitle = taskMatch[3] ? taskMatch[3].trim().replaceAll('\n', ' ') : undefined; - if (!taskTitle) { + if (!taskTitle || taskTitle.length > CONST.TITLE_CHARACTER_LIMIT) { return false; } diff --git a/src/pages/home/ForYouSection/ConciergePromptBox.tsx b/src/pages/home/ForYouSection/ConciergePromptBox.tsx index 223b3232697b..d71603f8f49b 100644 --- a/src/pages/home/ForYouSection/ConciergePromptBox.tsx +++ b/src/pages/home/ForYouSection/ConciergePromptBox.tsx @@ -1,5 +1,6 @@ import AttachmentPicker from '@components/AttachmentPicker'; import Composer from '@components/Composer'; +import ExceededCommentLength from '@components/ExceededCommentLength'; import Icon from '@components/Icon'; import PopoverMenu from '@components/PopoverMenu'; import {PressableWithoutFeedback} from '@components/Pressable'; @@ -23,6 +24,7 @@ import DateUtils from '@libs/DateUtils'; import getButtonState from '@libs/getButtonState'; import SubmitDraftButton from '@pages/inbox/report/ReportActionCompose/SubmitDraftButton'; +import useDebouncedCommentMaxLengthValidation from '@pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation'; import variables from '@styles/variables'; @@ -68,6 +70,8 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo const {calculatePopoverPosition} = usePopoverPosition(); const [value, setValue] = useState(''); + const {debouncedCommentMaxLengthValidation, exceededMaxLength, isExceedingMaxLength, isTaskTitle} = useDebouncedCommentMaxLengthValidation({reportID: conciergeTargetReportID}); + // Composer is a controlled input: the caret position must be tracked and fed back in (with // shouldCalculateCaretPosition), otherwise every value update re-renders it with the caret at the start. const [selection, setSelection] = useState({start: 0, end: 0}); @@ -79,6 +83,9 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo const clearInput = () => { setValue(''); setSelection({start: 0, end: 0}); + + debouncedCommentMaxLengthValidation(''); + debouncedCommentMaxLengthValidation.flush(); }; const sendAttachment = (attachments: FileObject | FileObject[]) => { @@ -111,10 +118,12 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo // Default to the short copy until measured, so it never flashes a wrapped long placeholder that then collapses. const longPlaceholderFitsOneLine = longPlaceholderHeight !== null && longPlaceholderHeight <= SINGLE_LINE_PLACEHOLDER_MAX_HEIGHT; const placeholder = shouldUseNarrowLayout || !longPlaceholderFitsOneLine ? shortPlaceholder : longPlaceholder; - const canSubmit = shouldShowAskConcierge && value.trim().length > 0; + const canSubmit = shouldShowAskConcierge && value.trim().length > 0 && !isExceedingMaxLength; + + const canAddAttachment = shouldShowAskConcierge && !isExceedingMaxLength; const submit = () => { - if (!canSubmit) { + if (!canSubmit || debouncedCommentMaxLengthValidation.flush() === false) { return; } askConcierge(value); @@ -140,123 +149,141 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo {dateLabel} {greeting} - - - - - - {({openPicker}) => { - const triggerAttachmentPicker = () => openPicker({onPicked: pickAttachments}); - return ( - <> - - { - e?.preventDefault(); - actionButtonRef.current?.blur(); - setIsMenuVisible((prev) => !prev); + + + + + + + {({openPicker}) => { + const triggerAttachmentPicker = () => openPicker({onPicked: pickAttachments}); + return ( + <> + + { + e?.preventDefault(); + actionButtonRef.current?.blur(); + setIsMenuVisible((prev) => !prev); + }} + style={({hovered, pressed}) => [ + styles.composerSizeButton, + StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered && canAddAttachment, pressed && canAddAttachment)), + ]} + > + {({hovered, pressed}) => ( + + )} + + + setIsMenuVisible(false)} + onItemSelected={() => { + setIsMenuVisible(false); + + // On Safari the file picker must be opened from within the user-initiated + // event handler, so it can't wait for the popover to finish closing. + if (isSafari()) { + triggerAttachmentPicker(); + return; + } + close(triggerAttachmentPicker); + }} + anchorPosition={popoverAnchorPosition ?? {horizontal: 0, vertical: 0}} + anchorAlignment={{ + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, }} - style={({hovered, pressed}) => [ - styles.composerSizeButton, - StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered && shouldShowAskConcierge, pressed && shouldShowAskConcierge)), + menuItems={[ + { + icon: icons.Paperclip, + text: translate('reportActionCompose.addAttachment'), + shouldCallAfterModalHide: shouldUseNarrowLayout, + }, ]} - > - {({hovered, pressed}) => ( - - )} - - - setIsMenuVisible(false)} - onItemSelected={() => { - setIsMenuVisible(false); - - // On Safari the file picker must be opened from within the user-initiated - // event handler, so it can't wait for the popover to finish closing. - if (isSafari()) { - triggerAttachmentPicker(); - return; - } - close(triggerAttachmentPicker); - }} - anchorPosition={popoverAnchorPosition ?? {horizontal: 0, vertical: 0}} - anchorAlignment={{ - horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, - vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, - }} - menuItems={[ - { - icon: icons.Paperclip, - text: translate('reportActionCompose.addAttachment'), - shouldCallAfterModalHide: shouldUseNarrowLayout, - }, - ]} - anchorRef={actionButtonRef} - /> - - ); - }} - + anchorRef={actionButtonRef} + /> + + ); + }} + + + - - - - setSelection(event.nativeEvent.selection)} - shouldCalculateCaretPosition - onFocus={() => setIsFocused(true)} - onBlur={() => setIsFocused(false)} - onKeyPress={handleKeyPress} - maxLines={MAX_INPUT_LINES} - multiline - textAlignVertical="top" - placeholder={placeholder} - placeholderTextColor={theme.placeholderText} - accessibilityLabel={placeholder} - /> - {/* Hidden probe stretched to the input's width. Its height reveals whether the long placeholder wraps past one line. */} - setLongPlaceholderHeight(event.nativeEvent.layout.height)} - > - + { + setValue(text); + debouncedCommentMaxLengthValidation(text); + }} + selection={selection} + onSelectionChange={(event) => setSelection(event.nativeEvent.selection)} + shouldCalculateCaretPosition + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + onKeyPress={handleKeyPress} + maxLines={MAX_INPUT_LINES} + multiline + textAlignVertical="top" + placeholder={placeholder} + placeholderTextColor={theme.placeholderText} + accessibilityLabel={placeholder} + /> + {/* Hidden probe stretched to the input's width. Its height reveals whether the long placeholder wraps past one line. */} + setLongPlaceholderHeight(event.nativeEvent.layout.height)} > - {longPlaceholder} - + + {longPlaceholder} + + + + {/* Mirror ComposerSendButton: the justifyContentEnd wrapper stretches to the row height and anchors the send button to the bottom. */} + + - {/* Mirror ComposerSendButton: the justifyContentEnd wrapper stretches to the row height and anchors the send button to the bottom. */} - - - + )} {PDFValidationComponent} diff --git a/tests/actions/TaskTest.ts b/tests/actions/TaskTest.ts index ba007a82bc2e..d92cab7e9911 100644 --- a/tests/actions/TaskTest.ts +++ b/tests/actions/TaskTest.ts @@ -1059,6 +1059,24 @@ describe('actions/Task', () => { expect(writeSpy).not.toHaveBeenCalled(); }); + it('does not create a task when the title exceeds the title character limit', () => { + // Given a shorthand whose title is one character over the limit the composer enforces + const overLimitTitle = 'a'.repeat(CONST.TITLE_CHARACTER_LIMIT + 1); + + // Then it is refused, so the caller falls back to sending it as a plain comment + expect(createTaskFromMarkdown({text: `[] ${overLimitTitle}`, parentReport, currentUserPersonalDetails, quickAction: undefined})).toBe(false); + expect(writeSpy).not.toHaveBeenCalled(); + }); + + it('creates a task from a title that is exactly at the title character limit', () => { + // Given a shorthand whose title is exactly at the limit + const maxLengthTitle = 'a'.repeat(CONST.TITLE_CHARACTER_LIMIT); + + // Then the task is still created + expect(createTaskFromMarkdown({text: `[] ${maxLengthTitle}`, parentReport, currentUserPersonalDetails, quickAction: undefined})).toBe(true); + expect(writeSpy).toHaveBeenCalledWith(WRITE_COMMANDS.CREATE_TASK, expect.anything(), expect.anything()); + }); + it('does not create a task when there is no parent report', () => { expect(createTaskFromMarkdown({text: '[] Buy milk', parentReport: undefined, currentUserPersonalDetails, quickAction: undefined})).toBe(false); expect(writeSpy).not.toHaveBeenCalled(); From 32461e3d263befd1a501ca4263afbea7be5d734d Mon Sep 17 00:00:00 2001 From: GCyganek Date: Wed, 26 Aug 2026 16:16:34 +0200 Subject: [PATCH 6/7] Fix ExceededCommentLength adding more space below composer --- src/pages/home/ForYouSection/ConciergePromptBox.tsx | 12 +++++++----- src/styles/index.ts | 9 +++++++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/pages/home/ForYouSection/ConciergePromptBox.tsx b/src/pages/home/ForYouSection/ConciergePromptBox.tsx index a55cb451e310..af38746cd31d 100644 --- a/src/pages/home/ForYouSection/ConciergePromptBox.tsx +++ b/src/pages/home/ForYouSection/ConciergePromptBox.tsx @@ -159,7 +159,7 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo {dateLabel} {greeting} - + {!!exceededMaxLength && ( - + + + )} {PDFValidationComponent} diff --git a/src/styles/index.ts b/src/styles/index.ts index cd6b56a5b1a2..e688492a680e 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -7231,6 +7231,15 @@ const plainStyles = (theme: ThemeColors) => minHeight: COMPOSER_SIZE_BUTTON_SIZE, }, + // Overlays the exceeded-length message just below the compose box so showing it never grows the box's + // footprint and pushes the content underneath down. + conciergePromptBoxExceededLength: { + position: 'absolute', + top: '100%', + left: 0, + right: 0, + }, + // Hidden probe that measures whether the long placeholder wraps. The paddingRight renders it a few px // narrower than the composer so it wraps first, avoiding a flash at borderline widths. conciergePromptBoxPlaceholderProbe: { From 44d741dd35490f1bf4c0cfb1f65327ed2115b1e8 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Thu, 27 Aug 2026 10:22:20 +0200 Subject: [PATCH 7/7] Additional bottom padding when only showing concierge prompt box --- src/pages/home/ForYouSection/index.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pages/home/ForYouSection/index.tsx b/src/pages/home/ForYouSection/index.tsx index fe5f92f0762a..cf747b998727 100644 --- a/src/pages/home/ForYouSection/index.tsx +++ b/src/pages/home/ForYouSection/index.tsx @@ -213,10 +213,13 @@ function ForYouSection({isConciergeMenuVisible, setIsConciergeMenuVisible}: ForY isOnboardingStatusKnown, }); + const willOnlyShowConciergePromptBox = timeSensitiveItems.length === 0 && hideForYou; + // The card always renders so the Concierge input stays on the home page. `hideForYou` only gates the "For you" // heading and todos or empty-state below it. When hidden with no time-sensitive content, the card is just the box. return (