Skip to content

Commit 31e9f3b

Browse files
authored
Merge pull request Expensify#88727 from software-mansion-labs/feat/ask-consierge-search-switcher
"Ask Concierge" option in the search router
2 parents 3fb4b65 + 71e19fb commit 31e9f3b

25 files changed

Lines changed: 198 additions & 44 deletions

File tree

src/CONST/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7982,6 +7982,7 @@ const CONST = {
79827982
AUTOCOMPLETE_SUGGESTION: 'autocompleteSuggestion',
79837983
SEARCH: 'searchItem',
79847984
FIND_ITEM: 'findItem',
7985+
ASK_CONCIERGE: 'askConcierge',
79857986
},
79867987
SEARCH_USER_FRIENDLY_KEYS: {
79877988
TYPE: 'type',

src/ONYXKEYS.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,9 @@ const ONYXKEYS = {
600600
/** Company cards custom names */
601601
NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES: 'nvp_expensify_ccCustomNames',
602602

603+
/** Whether to kick off the "Concierge is thinking" indicator when AgentZeroStatusGate mounts */
604+
CONCIERGE_THINKING_KICKOFF: 'conciergeThinkingKickoff',
605+
603606
/** The user's Concierge reportID */
604607
CONCIERGE_REPORT_ID: 'conciergeReportID',
605608

@@ -1520,6 +1523,7 @@ type OnyxValuesMapping = {
15201523
[ONYXKEYS.LAST_ROUTE]: string;
15211524
[ONYXKEYS.IS_USING_IMPORTED_STATE]: boolean;
15221525
[ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES]: Record<string, string>;
1526+
[ONYXKEYS.CONCIERGE_THINKING_KICKOFF]: boolean;
15231527
[ONYXKEYS.CONCIERGE_REPORT_ID]: string;
15241528
[ONYXKEYS.SELF_DM_REPORT_ID]: string;
15251529
[ONYXKEYS.SHARE_UNKNOWN_USER_DETAILS]: Participant;

src/components/Search/SearchAutocompleteList.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ type SearchAutocompleteListProps = {
5555
/** Callback to trigger search action * */
5656
handleSearch: (value: string) => void;
5757

58-
/** An optional item to always display on the top of the router list */
59-
searchQueryItem?: SearchQueryItem;
58+
/** Optional items to always display at the top of the router list */
59+
searchQueryItems?: SearchQueryItem[];
6060

6161
/** Any extra sections that should be displayed in the router list. */
6262
getAdditionalSections?: GetAdditionalSectionsCallback;
@@ -131,7 +131,7 @@ function SearchRouterItem(props: UserListItemProps<AutocompleteListItem> | Searc
131131
function SearchAutocompleteList({
132132
autocompleteQueryValue,
133133
handleSearch,
134-
searchQueryItem,
134+
searchQueryItems,
135135
getAdditionalSections,
136136
onListItemPress,
137137
shouldSubscribeToArrowKeyEvents = true,
@@ -402,8 +402,8 @@ function SearchAutocompleteList({
402402
nextSuggestionsCount += section.data.filter((item) => item.keyForList !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.FIND_ITEM).length;
403403
};
404404

405-
if (searchQueryItem) {
406-
pushSection({data: [searchQueryItem as AutocompleteListItem], sectionIndex: sectionIndex++});
405+
if (searchQueryItems && searchQueryItems.length > 0) {
406+
pushSection({data: searchQueryItems as AutocompleteListItem[], sectionIndex: sectionIndex++});
407407
}
408408

409409
const additionalSections = getAdditionalSections?.(searchOptions, sectionIndex);
@@ -493,7 +493,7 @@ function SearchAutocompleteList({
493493
recentReportsOptions,
494494
recentSearchesData,
495495
searchOptions,
496-
searchQueryItem,
496+
searchQueryItems,
497497
styles,
498498
translate,
499499
isLoadingOptions,

src/components/Search/SearchList/ListItem/SearchQueryListItem.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import type IconAsset from '@src/types/utils/IconAsset';
1313

1414
type SearchQueryItem = ListItem & {
1515
singleIcon?: IconAsset;
16+
/** Whether to apply the theme fill color to the icon. Set to false for multi-colored icons like avatars. Defaults to true. */
17+
shouldIconApplyFill?: boolean;
1618
searchItemType?: ValueOf<typeof CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE>;
1719
searchQuery?: string;
1820
autocompleteID?: string;
@@ -56,7 +58,7 @@ function SearchQueryListItem({item, isFocused, showTooltip, onSelectRow, onFocus
5658
{!!item.singleIcon && (
5759
<Icon
5860
src={item.singleIcon}
59-
fill={theme.icon}
61+
fill={item.shouldIconApplyFill !== false ? theme.icon : undefined}
6062
additionalStyles={styles.mr3}
6163
medium
6264
/>

src/components/Search/SearchPageHeader/SearchPageInputNarrow.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ function SearchPageInputNarrow({queryJSON, searchRouterListVisible, hideSearchRo
2525
const {
2626
autocompleteSubstitutions,
2727
autocompleteQueryValue,
28-
searchQueryItem,
28+
searchQueryItems,
2929
selection,
3030
textInputRef,
3131
textInputValue,
@@ -80,7 +80,7 @@ function SearchPageInputNarrow({queryJSON, searchRouterListVisible, hideSearchRo
8080
<SearchAutocompleteList
8181
autocompleteQueryValue={autocompleteQueryValue}
8282
handleSearch={handleSearchAction}
83-
searchQueryItem={searchQueryItem}
83+
searchQueryItems={searchQueryItems}
8484
onListItemPress={onListItemPress}
8585
textInputRef={textInputRef}
8686
autocompleteSubstitutions={autocompleteSubstitutions}

src/components/Search/SearchPageHeader/SearchPageInputWide.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ function SearchPageInputWide({queryJSON, handleSearch}: SearchPageInputWideProps
2828
const {
2929
autocompleteSubstitutions,
3030
autocompleteQueryValue,
31-
searchQueryItem,
31+
searchQueryItems,
3232
selection,
3333
textInputRef,
3434
textInputValue,
@@ -97,7 +97,7 @@ function SearchPageInputWide({queryJSON, handleSearch}: SearchPageInputWideProps
9797
<SearchAutocompleteList
9898
autocompleteQueryValue={autocompleteQueryValue}
9999
handleSearch={handleSearchAction}
100-
searchQueryItem={searchQueryItem}
100+
searchQueryItems={searchQueryItems}
101101
onListItemPress={onListItemPress}
102102
ref={listRef}
103103
shouldSubscribeToArrowKeyEvents={isAutocompleteListVisible}

src/components/Search/SearchPageHeader/useSearchPageInput.tsx

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -198,15 +198,17 @@ function useSearchPageInput({queryJSON, onSearch, onSubmit}: UseSearchPageInputP
198198
}
199199
}
200200

201-
const searchQueryItem = textInputValue
202-
? {
203-
text: textInputValue,
204-
singleIcon: expensifyIcons.MagnifyingGlass,
205-
searchQuery: textInputValue,
206-
itemStyle: styles.activeComponentBG,
207-
keyForList: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.FIND_ITEM,
208-
searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH,
209-
}
201+
const searchQueryItems = textInputValue
202+
? [
203+
{
204+
text: textInputValue,
205+
singleIcon: expensifyIcons.MagnifyingGlass,
206+
searchQuery: textInputValue,
207+
itemStyle: styles.activeComponentBG,
208+
keyForList: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.FIND_ITEM,
209+
searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH,
210+
},
211+
]
210212
: undefined;
211213

212214
return {
@@ -216,7 +218,7 @@ function useSearchPageInput({queryJSON, onSearch, onSubmit}: UseSearchPageInputP
216218
personalAndWorkspaceCards,
217219
personalDetails,
218220
reports,
219-
searchQueryItem,
221+
searchQueryItems,
220222
selection,
221223
textInputRef,
222224
textInputValue,

src/components/Search/SearchRouter/SearchRouter.tsx

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {getQueryWithSubstitutions} from './getQueryWithSubstitutions';
4949
import {getUpdatedSubstitutionsMap} from './getUpdatedSubstitutionsMap';
5050
import {getContextualReportData, getContextualSearchAutocompleteKey, getContextualSearchQuery} from './SearchRouterUtils';
5151
import updateAutocompleteSubstitutionsForSelection from './updateAutocompleteSubstitutionsForSelection';
52+
import useAskConcierge from './useAskConcierge';
5253

5354
const privateIsArchivedSelector = (nvp: {private_isArchived?: string} | undefined): boolean | undefined => !!nvp?.private_isArchived;
5455

@@ -72,7 +73,8 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla
7273
const personalDetails = usePersonalDetails();
7374
const {shouldUseNarrowLayout} = useResponsiveLayout();
7475
const listRef = useRef<SelectionListWithSectionsHandle>(null);
75-
const expensifyIcons = useMemoizedLazyExpensifyIcons(['MagnifyingGlass']);
76+
const expensifyIcons = useMemoizedLazyExpensifyIcons(['MagnifyingGlass', 'ConciergeAvatar']);
77+
const askConcierge = useAskConcierge();
7678

7779
// The actual input text that the user sees
7880
const [textInputValue, , setTextInputValue] = useDebouncedState('', 500);
@@ -198,15 +200,26 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla
198200
],
199201
);
200202

201-
const searchQueryItem = textInputValue
202-
? {
203-
text: textInputValue,
204-
singleIcon: expensifyIcons.MagnifyingGlass,
205-
searchQuery: textInputValue,
206-
itemStyle: styles.activeComponentBG,
207-
keyForList: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.FIND_ITEM,
208-
searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH,
209-
}
203+
const searchQueryItems = textInputValue
204+
? [
205+
{
206+
text: textInputValue,
207+
singleIcon: expensifyIcons.MagnifyingGlass,
208+
searchQuery: textInputValue,
209+
itemStyle: styles.activeComponentBG,
210+
keyForList: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.FIND_ITEM,
211+
searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH,
212+
},
213+
{
214+
text: translate('search.askConcierge', textInputValue),
215+
singleIcon: expensifyIcons.ConciergeAvatar,
216+
shouldIconApplyFill: false,
217+
searchQuery: textInputValue,
218+
itemStyle: styles.activeComponentBG,
219+
keyForList: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.ASK_CONCIERGE,
220+
searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.ASK_CONCIERGE,
221+
},
222+
]
210223
: undefined;
211224

212225
const shouldScrollRef = useRef(false);
@@ -308,6 +321,12 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla
308321
setAutocompleteSubstitutions,
309322
});
310323
setFocusAndScrollToRight();
324+
} else if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.ASK_CONCIERGE) {
325+
const {searchQuery} = item;
326+
backHistory(() => {
327+
askConcierge(searchQuery);
328+
});
329+
onRouterClose();
311330
} else {
312331
submitSearch(item.searchQuery, item.keyForList !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.FIND_ITEM);
313332
}
@@ -335,13 +354,14 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla
335354
betas,
336355
contextualPoliciesMap,
337356
contextualReportsMap,
357+
askConcierge,
338358
],
339359
);
340360

341361
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => {
342362
onRouterClose();
343363
});
344-
const updateAndScrollToFocusedIndex = useCallback(() => listRef.current?.updateAndScrollToFocusedIndex(1, true), []);
364+
const updateAndScrollToFocusedIndex = useCallback(() => listRef.current?.updateAndScrollToFocusedIndex(searchQueryItems?.length ?? 1, true), [searchQueryItems?.length]);
345365

346366
const modalWidth = shouldUseNarrowLayout ? styles.w100 : {width: variables.searchRouterPopoverWidth};
347367

@@ -387,7 +407,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla
387407
<DeferredAutocompleteList
388408
autocompleteQueryValue={autocompleteQueryValue || textInputValue}
389409
handleSearch={searchInServer}
390-
searchQueryItem={searchQueryItem}
410+
searchQueryItems={searchQueryItems}
391411
getAdditionalSections={getAdditionalSections}
392412
onListItemPress={onListItemPress}
393413
onHighlightFirstItem={updateAndScrollToFocusedIndex}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
2+
import useDelegateAccountID from '@hooks/useDelegateAccountID';
3+
import useOnyx from '@hooks/useOnyx';
4+
import useOpenConciergeAnywhere from '@hooks/useOpenConciergeAnywhere';
5+
import useSidePanelReportID from '@hooks/useSidePanelReportID';
6+
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
7+
import {addComment, setConciergeThinkingKickoff} from '@userActions/Report';
8+
import CONST from '@src/CONST';
9+
import ONYXKEYS from '@src/ONYXKEYS';
10+
11+
/**
12+
* Returns a callback that opens the side panel (or Concierge chat on native)
13+
* and sends the provided search query as a message.
14+
*/
15+
function useAskConcierge() {
16+
const sidePanelReportID = useSidePanelReportID();
17+
const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID);
18+
const {openConciergeAnywhere, isInSidePanel} = useOpenConciergeAnywhere();
19+
const targetReportID = (isInSidePanel ? sidePanelReportID : undefined) ?? conciergeReportID;
20+
const [targetReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(targetReportID)}`);
21+
const {timezone, accountID: currentUserAccountID} = useCurrentUserPersonalDetails();
22+
const delegateAccountID = useDelegateAccountID();
23+
24+
return (searchQuery: string) => {
25+
openConciergeAnywhere();
26+
if (!targetReport || !targetReportID) {
27+
return;
28+
}
29+
setConciergeThinkingKickoff();
30+
addComment({
31+
report: targetReport,
32+
notifyReportID: targetReportID,
33+
ancestors: [],
34+
text: searchQuery,
35+
timezoneParam: timezone ?? CONST.DEFAULT_TIME_ZONE,
36+
currentUserAccountID,
37+
shouldPlaySound: true,
38+
isInSidePanel,
39+
delegateAccountID,
40+
});
41+
};
42+
}
43+
44+
export default useAskConcierge;

src/components/SidePanel/SidePanelContextProvider.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,9 @@ function SidePanelContextProvider({children}: PropsWithChildren) {
9393

9494
if (prevShouldHideSidePanel !== shouldHideSidePanel) {
9595
setPrevShouldHideSidePanel(shouldHideSidePanel);
96-
if (!shouldHideSidePanel) {
96+
if (shouldHideSidePanel) {
97+
setSessionStartTime(null);
98+
} else if (!sessionStartTime) {
9799
setSessionStartTime(DateUtils.getDBTime());
98100
}
99101
}
@@ -135,6 +137,11 @@ function SidePanelContextProvider({children}: PropsWithChildren) {
135137
focusComposerWithDelay(ReportActionComposeFocusManager.composerRef.current, CONST.SIDE_PANEL_ANIMATED_TRANSITION + CONST.COMPOSER_FOCUS_DELAY)(true);
136138
};
137139

140+
const openSidePanel = () => {
141+
setSessionStartTime(DateUtils.getDBTime());
142+
SidePanelActions.openSidePanel(!isExtraLargeScreenWidth);
143+
};
144+
138145
// Because of the React Compiler we don't need to memoize it manually
139146
// eslint-disable-next-line react/jsx-no-constructed-context-values
140147
const stateValue = {
@@ -153,7 +160,7 @@ function SidePanelContextProvider({children}: PropsWithChildren) {
153160
// Because of the React Compiler we don't need to memoize it manually
154161
// eslint-disable-next-line react/jsx-no-constructed-context-values
155162
const actionsValue = {
156-
openSidePanel: () => SidePanelActions.openSidePanel(!isExtraLargeScreenWidth),
163+
openSidePanel,
157164
closeSidePanel,
158165
};
159166

0 commit comments

Comments
 (0)