From cdea8816420a096e39808318d22162f8e05b9fba Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 7 Sep 2026 21:29:47 +0200 Subject: [PATCH 001/156] Ask Concierge design --- assets/images/chart-pie.svg | 1 + .../Icon/chunks/expensify-icons.chunk.ts | 2 + src/hooks/useConciergeAskState.ts | 58 +++++++++++++++++ src/languages/de.ts | 21 +++++- src/languages/el.ts | 18 ++++- src/languages/en.ts | 18 ++++- src/languages/es.ts | 18 ++++- src/languages/fr.ts | 21 +++++- src/languages/it.ts | 21 +++++- src/languages/ja.ts | 21 +++++- src/languages/nl.ts | 21 +++++- src/languages/pl.ts | 21 +++++- src/languages/pt-BR.ts | 21 +++++- src/languages/zh-hans.ts | 21 +++++- src/pages/inbox/ConciergeSessionContext.tsx | 12 +++- src/pages/inbox/HeaderView.tsx | 30 ++++++++- .../report/ConciergeChatHistoryToggle.tsx | 62 ++++++++++++++++++ .../inbox/report/ConciergeQuickActions.tsx | 65 +++++++++++++++++++ src/pages/inbox/report/ConciergeWelcome.tsx | 27 ++++++++ .../ReportActionCompose/ComposerInput.tsx | 6 ++ .../inbox/report/ReportActionItemCreated.tsx | 7 ++ src/pages/inbox/report/ReportActionsList.tsx | 4 +- .../inbox/report/ReportActionsListHeader.tsx | 13 ++-- src/pages/inbox/report/ReportFooter.tsx | 6 ++ .../report/ShowPreviousMessagesButton.tsx | 8 ++- src/styles/index.ts | 5 ++ tests/unit/ShowPreviousMessagesButtonTest.tsx | 11 ++++ 27 files changed, 518 insertions(+), 21 deletions(-) create mode 100644 assets/images/chart-pie.svg create mode 100644 src/hooks/useConciergeAskState.ts create mode 100644 src/pages/inbox/report/ConciergeChatHistoryToggle.tsx create mode 100644 src/pages/inbox/report/ConciergeQuickActions.tsx create mode 100644 src/pages/inbox/report/ConciergeWelcome.tsx diff --git a/assets/images/chart-pie.svg b/assets/images/chart-pie.svg new file mode 100644 index 000000000000..f5099496e1e1 --- /dev/null +++ b/assets/images/chart-pie.svg @@ -0,0 +1 @@ + diff --git a/src/components/Icon/chunks/expensify-icons.chunk.ts b/src/components/Icon/chunks/expensify-icons.chunk.ts index 4d388c980589..2b519f85fd99 100644 --- a/src/components/Icon/chunks/expensify-icons.chunk.ts +++ b/src/components/Icon/chunks/expensify-icons.chunk.ts @@ -44,6 +44,7 @@ import CarWithKey from '@assets/images/car-with-key.svg'; import Car from '@assets/images/car.svg'; import CaretUpDown from '@assets/images/caret-up-down.svg'; import Cash from '@assets/images/cash.svg'; +import ChartPie from '@assets/images/chart-pie.svg'; import ChatBubbleCounter from '@assets/images/chatbubble-counter.svg'; import ChatBubbleReply from '@assets/images/chatbubble-reply.svg'; import ChatBubbleUnread from '@assets/images/chatbubble-unread.svg'; @@ -334,6 +335,7 @@ const Expensicons = { CarPlus, Cash, CertiniaSquare, + ChartPie, ChatBubble, ChatBubbles, Checkbox, diff --git a/src/hooks/useConciergeAskState.ts b/src/hooks/useConciergeAskState.ts new file mode 100644 index 000000000000..e2ed6d4ae3f1 --- /dev/null +++ b/src/hooks/useConciergeAskState.ts @@ -0,0 +1,58 @@ +import {isCreatedAction, isCurrentUserPendingAddAction} from '@libs/ReportActionsUtils'; + +import {useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportActions} from '@src/types/onyx/ReportAction'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useIsInSidePanel from './useIsInSidePanel'; +import useOnyx from './useOnyx'; +import usePermissions from './usePermissions'; + +type ConciergeAskState = { + /** Whether to show the welcome empty state and the quick actions that go with it */ + shouldShowWelcome: boolean; + + /** Whether to label the composer as the start of a new question, which it is once earlier history is expanded */ + shouldLabelComposerAsNewQuestion: boolean; +}; + +/** + * Drives the Ask Concierge screen in the main Concierge DM. The empty state lives in the report actions + * list and the composer label and quick actions live in the report footer, and those are siblings, so + * both derive their state here rather than one passing it to the other. + */ +function useConciergeAskState(reportID: string | undefined): ConciergeAskState { + const {isBetaEnabled} = usePermissions(); + const isInSidePanel = useIsInSidePanel(); + const {sessionStartTime, showFullHistory} = useConciergeSessionState(); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + + // Derive the boolean inside the Onyx selector so the screen re-renders only when session activity + // flips, not on every report-action change in the Concierge chat. + const hasSessionActivitySelector = (actions: OnyxEntry) => { + if (!actions || !sessionStartTime) { + return false; + } + return Object.values(actions).some((action) => isCurrentUserPendingAddAction(action, currentUserAccountID) || (!isCreatedAction(action) && action.created >= sessionStartTime)); + }; + const [hasSessionActivity] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + selector: hasSessionActivitySelector, + }); + + // Waiting for the session boundary keeps an existing conversation from flashing the welcome screen on open. + const isAskConciergeChat = !!reportID && reportID === conciergeReportID && !isInSidePanel && !!sessionStartTime && isBetaEnabled(CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD); + + return { + shouldShowWelcome: isAskConciergeChat && !showFullHistory && !hasSessionActivity, + shouldLabelComposerAsNewQuestion: isAskConciergeChat && showFullHistory && !hasSessionActivity, + }; +} + +export default useConciergeAskState; +export type {ConciergeAskState}; diff --git a/src/languages/de.ts b/src/languages/de.ts index 670707b049f5..13c2c426226e 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -488,7 +488,26 @@ const translations: TranslationDeepObject = { quarter: 'Quartal', vacationDelegate: 'Urlaubsvertretung', expensifyLogo: 'Expensify-Logo', - concierge: {greeting: 'Hallo, wie kann ich helfen?', showHistory: 'Verlauf anzeigen'}, + concierge: { + greeting: 'Hallo, wie kann ich helfen?', + subtitle: 'Dein persönlicher KI-Assistent', + welcomeDescription: 'Concierge kann Fragen beantworten, Ausgaben aktualisieren und mehr.', + viewChatHistory: 'Chatverlauf anzeigen', + hideChatHistory: 'Chatverlauf ausblenden', + askNewQuestion: 'Neue Frage stellen', + newChat: 'Neuer Chat', + composerPlaceholder: 'Wie viel habe ich letzten Monat ausgegeben?', + quickActions: { + createExpense: 'Ausgabe erstellen', + createExpensePrompt: 'Erstelle eine Ausgabe', + analyzeSpend: 'Ausgaben analysieren', + analyzeSpendPrompt: 'Wie viel habe ich letzten Monat ausgegeben?', + configureWorkspace: 'Arbeitsbereich einrichten', + configureWorkspacePrompt: 'Hilf mir, meinen Arbeitsbereich einzurichten', + getSupport: 'Support erhalten', + getSupportPrompt: 'Ich brauche bei etwas Hilfe', + }, + }, duplicateReport: 'Duplizierten Bericht', approver: 'Genehmiger', goToConcierge: 'Zu Concierge', diff --git a/src/languages/el.ts b/src/languages/el.ts index 5345331a15a0..e303b5053c0e 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -492,7 +492,23 @@ const translations: TranslationDeepObject = { tagGLCode: 'Ετικέτα κωδικού GL', concierge: { greeting: 'Γεια σας, πώς μπορώ να σας βοηθήσω;', - showHistory: 'Εμφάνιση ιστορικού', + subtitle: 'Ο προσωπικός σας βοηθός AI', + welcomeDescription: 'Ο Concierge μπορεί να απαντήσει σε ερωτήσεις, να ενημερώσει δαπάνες και άλλα.', + viewChatHistory: 'Προβολή ιστορικού συνομιλιών', + hideChatHistory: 'Απόκρυψη ιστορικού συνομιλιών', + askNewQuestion: 'Κάντε μια νέα ερώτηση', + newChat: 'Νέα συνομιλία', + composerPlaceholder: 'Πόσα ξόδεψα τον προηγούμενο μήνα;', + quickActions: { + createExpense: 'Δημιουργία δαπάνης', + createExpensePrompt: 'Δημιούργησε μια δαπάνη', + analyzeSpend: 'Ανάλυση δαπανών', + analyzeSpendPrompt: 'Πόσα ξόδεψα τον προηγούμενο μήνα;', + configureWorkspace: 'Ρύθμιση χώρου εργασίας', + configureWorkspacePrompt: 'Βοήθησέ με να ρυθμίσω τον χώρο εργασίας μου', + getSupport: 'Λήψη υποστήριξης', + getSupportPrompt: 'Χρειάζομαι βοήθεια με κάτι', + }, }, vacationDelegate: 'Εκπρόσωπος διακοπών', expensifyLogo: 'Λογότυπο Expensify', diff --git a/src/languages/en.ts b/src/languages/en.ts index 7ebbdcde6fb3..97e3f6a9740c 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -538,7 +538,23 @@ const translations = { tagGLCode: 'Tag GL code', concierge: { greeting: 'Hi there, how can I help?', - showHistory: 'Show history', + subtitle: 'Your personal AI assistant', + welcomeDescription: 'Concierge can answer questions, update expenses, and more.', + viewChatHistory: 'View chat history', + hideChatHistory: 'Hide chat history', + askNewQuestion: 'Ask a new question', + newChat: 'New chat', + composerPlaceholder: 'How much did I spend last month?', + quickActions: { + createExpense: 'Create expense', + createExpensePrompt: 'Create an expense', + analyzeSpend: 'Analyze spend', + analyzeSpendPrompt: 'How much did I spend last month?', + configureWorkspace: 'Configure workspace', + configureWorkspacePrompt: 'Help me configure my workspace', + getSupport: 'Get support', + getSupportPrompt: 'I need help with something', + }, }, vacationDelegate: 'Vacation delegate', expensifyLogo: 'Expensify logo', diff --git a/src/languages/es.ts b/src/languages/es.ts index 073dd24d79db..37baa13f0575 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -481,7 +481,23 @@ const translations: TranslationDeepObject = { quarter: 'Trimestre', concierge: { greeting: 'Hola, ¿en qué puedo ayudarte?', - showHistory: 'Mostrar historial', + subtitle: 'Tu asistente de IA personal', + welcomeDescription: 'Concierge puede responder preguntas, actualizar gastos y mucho más.', + viewChatHistory: 'Ver historial del chat', + hideChatHistory: 'Ocultar historial del chat', + askNewQuestion: 'Haz una nueva pregunta', + newChat: 'Nuevo chat', + composerPlaceholder: '¿Cuánto gasté el mes pasado?', + quickActions: { + createExpense: 'Crear gasto', + createExpensePrompt: 'Crea un gasto', + analyzeSpend: 'Analizar gastos', + analyzeSpendPrompt: '¿Cuánto gasté el mes pasado?', + configureWorkspace: 'Configurar espacio de trabajo', + configureWorkspacePrompt: 'Ayúdame a configurar mi espacio de trabajo', + getSupport: 'Obtener ayuda', + getSupportPrompt: 'Necesito ayuda con algo', + }, }, vacationDelegate: 'Delegado de vacaciones', expensifyLogo: 'Logo de Expensify', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index e8ca853b7569..13c936d8614b 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -488,7 +488,26 @@ const translations: TranslationDeepObject = { quarter: 'Trimestre', vacationDelegate: 'Délégué de vacances', expensifyLogo: 'Logo Expensify', - concierge: {greeting: 'Bonjour, comment puis-je vous aider ?', showHistory: 'Afficher l’historique'}, + concierge: { + greeting: 'Bonjour, comment puis-je vous aider ?', + subtitle: 'Votre assistant IA personnel', + welcomeDescription: 'Concierge peut répondre à vos questions, mettre à jour vos dépenses et plus encore.', + viewChatHistory: 'Afficher l’historique du chat', + hideChatHistory: 'Masquer l’historique du chat', + askNewQuestion: 'Poser une nouvelle question', + newChat: 'Nouveau chat', + composerPlaceholder: 'Combien ai-je dépensé le mois dernier ?', + quickActions: { + createExpense: 'Créer une dépense', + createExpensePrompt: 'Crée une dépense', + analyzeSpend: 'Analyser les dépenses', + analyzeSpendPrompt: 'Combien ai-je dépensé le mois dernier ?', + configureWorkspace: 'Configurer l’espace de travail', + configureWorkspacePrompt: 'Aide-moi à configurer mon espace de travail', + getSupport: 'Obtenir de l’aide', + getSupportPrompt: 'J’ai besoin d’aide pour quelque chose', + }, + }, duplicateReport: 'Note de frais en double', approver: 'Approbateur', goToConcierge: 'Aller à Concierge', diff --git a/src/languages/it.ts b/src/languages/it.ts index 86ea6baca0bf..98861b81ea9e 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -488,7 +488,26 @@ const translations: TranslationDeepObject = { quarter: 'Trimestre', vacationDelegate: 'Delega ferie', expensifyLogo: 'Logo Expensify', - concierge: {greeting: 'Ciao, come posso aiutarti?', showHistory: 'Mostra cronologia'}, + concierge: { + greeting: 'Ciao, come posso aiutarti?', + subtitle: 'Il tuo assistente AI personale', + welcomeDescription: 'Concierge può rispondere a domande, aggiornare spese e altro ancora.', + viewChatHistory: 'Mostra cronologia chat', + hideChatHistory: 'Nascondi cronologia chat', + askNewQuestion: 'Fai una nuova domanda', + newChat: 'Nuova chat', + composerPlaceholder: 'Quanto ho speso il mese scorso?', + quickActions: { + createExpense: 'Crea spesa', + createExpensePrompt: 'Crea una spesa', + analyzeSpend: 'Analizza le spese', + analyzeSpendPrompt: 'Quanto ho speso il mese scorso?', + configureWorkspace: 'Configura area di lavoro', + configureWorkspacePrompt: 'Aiutami a configurare la mia area di lavoro', + getSupport: 'Ottieni assistenza', + getSupportPrompt: 'Ho bisogno di aiuto con una cosa', + }, + }, duplicateReport: 'Report duplicato', approver: 'Approvante', goToConcierge: 'Vai a Concierge', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index c9bd9d9c8960..a46732111a1b 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -487,7 +487,26 @@ const translations: TranslationDeepObject = { quarter: '四半期', vacationDelegate: '休暇代理人', expensifyLogo: 'Expensifyロゴ', - concierge: {greeting: 'こんにちは、どのようにお手伝いできますか?', showHistory: '履歴を表示'}, + concierge: { + greeting: 'こんにちは、どのようにお手伝いできますか?', + subtitle: 'あなた専用のAIアシスタント', + welcomeDescription: 'Conciergeは質問への回答や経費の更新などを行えます。', + viewChatHistory: 'チャット履歴を表示', + hideChatHistory: 'チャット履歴を非表示', + askNewQuestion: '新しい質問をする', + newChat: '新しいチャット', + composerPlaceholder: '先月はいくら使いましたか?', + quickActions: { + createExpense: '経費を作成', + createExpensePrompt: '経費を作成して', + analyzeSpend: '支出を分析', + analyzeSpendPrompt: '先月はいくら使いましたか?', + configureWorkspace: 'ワークスペースを設定', + configureWorkspacePrompt: 'ワークスペースの設定を手伝って', + getSupport: 'サポートを受ける', + getSupportPrompt: '困っていることがあります', + }, + }, duplicateReport: 'レポートを複製', approver: '承認者', goToConcierge: 'Conciergeへ移動', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 4031271b6da1..6035992e57d4 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -487,7 +487,26 @@ const translations: TranslationDeepObject = { quarter: 'Kwartaal', vacationDelegate: 'Vertegenwoordiger tijdens vakantie', expensifyLogo: 'Expensify-logo', - concierge: {greeting: 'Hoi, waarmee kan ik je helpen?', showHistory: 'Geschiedenis weergeven'}, + concierge: { + greeting: 'Hoi, waarmee kan ik je helpen?', + subtitle: 'Je persoonlijke AI-assistent', + welcomeDescription: 'Concierge kan vragen beantwoorden, uitgaven bijwerken en meer.', + viewChatHistory: 'Chatgeschiedenis weergeven', + hideChatHistory: 'Chatgeschiedenis verbergen', + askNewQuestion: 'Stel een nieuwe vraag', + newChat: 'Nieuwe chat', + composerPlaceholder: 'Hoeveel heb ik vorige maand uitgegeven?', + quickActions: { + createExpense: 'Uitgave maken', + createExpensePrompt: 'Maak een uitgave', + analyzeSpend: 'Uitgaven analyseren', + analyzeSpendPrompt: 'Hoeveel heb ik vorige maand uitgegeven?', + configureWorkspace: 'Werkruimte instellen', + configureWorkspacePrompt: 'Help me mijn werkruimte in te stellen', + getSupport: 'Hulp krijgen', + getSupportPrompt: 'Ik heb ergens hulp bij nodig', + }, + }, duplicateReport: 'Dubbel rapport', approver: 'Fiatteur', goToConcierge: 'Ga naar Concierge', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 50a014fdd300..22d411f70a90 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -491,7 +491,26 @@ const translations: TranslationDeepObject = { quarter: 'Kwartał', vacationDelegate: 'Zastępca urlopowy', expensifyLogo: 'Logo Expensify', - concierge: {greeting: 'Cześć, w czym mogę pomóc?', showHistory: 'Pokaż historię'}, + concierge: { + greeting: 'Cześć, w czym mogę pomóc?', + subtitle: 'Twój osobisty asystent AI', + welcomeDescription: 'Concierge może odpowiadać na pytania, aktualizować wydatki i nie tylko.', + viewChatHistory: 'Pokaż historię czatu', + hideChatHistory: 'Ukryj historię czatu', + askNewQuestion: 'Zadaj nowe pytanie', + newChat: 'Nowy czat', + composerPlaceholder: 'Ile wydałem w zeszłym miesiącu?', + quickActions: { + createExpense: 'Utwórz wydatek', + createExpensePrompt: 'Utwórz wydatek', + analyzeSpend: 'Analizuj wydatki', + analyzeSpendPrompt: 'Ile wydałem w zeszłym miesiącu?', + configureWorkspace: 'Skonfiguruj przestrzeń roboczą', + configureWorkspacePrompt: 'Pomóż mi skonfigurować moją przestrzeń roboczą', + getSupport: 'Uzyskaj pomoc', + getSupportPrompt: 'Potrzebuję z czymś pomocy', + }, + }, duplicateReport: 'Zduplikowany raport', approver: 'Osoba zatwierdzająca', goToConcierge: 'Przejdź do Concierge', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index d3f8b357efa2..4072041bee4f 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -486,7 +486,26 @@ const translations: TranslationDeepObject = { quarter: 'Trimestre', vacationDelegate: 'Delegado de férias', expensifyLogo: 'Logo da Expensify', - concierge: {greeting: 'Oi, como posso ajudar?', showHistory: 'Mostrar histórico'}, + concierge: { + greeting: 'Oi, como posso ajudar?', + subtitle: 'Seu assistente de IA pessoal', + welcomeDescription: 'O Concierge pode responder perguntas, atualizar despesas e muito mais.', + viewChatHistory: 'Ver histórico do chat', + hideChatHistory: 'Ocultar histórico do chat', + askNewQuestion: 'Faça uma nova pergunta', + newChat: 'Novo chat', + composerPlaceholder: 'Quanto gastei no mês passado?', + quickActions: { + createExpense: 'Criar despesa', + createExpensePrompt: 'Crie uma despesa', + analyzeSpend: 'Analisar gastos', + analyzeSpendPrompt: 'Quanto gastei no mês passado?', + configureWorkspace: 'Configurar espaço de trabalho', + configureWorkspacePrompt: 'Ajude-me a configurar meu espaço de trabalho', + getSupport: 'Obter suporte', + getSupportPrompt: 'Preciso de ajuda com uma coisa', + }, + }, duplicateReport: 'Duplicar relatório', approver: 'Aprovador', goToConcierge: 'Ir para o Concierge', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 0355e53ad566..229ac87f64e0 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -483,7 +483,26 @@ const translations: TranslationDeepObject = { quarter: '季度', vacationDelegate: '休假代理', expensifyLogo: 'Expensify徽标', - concierge: {greeting: '你好,我能帮你做什么?', showHistory: '显示历史'}, + concierge: { + greeting: '你好,我能帮你做什么?', + subtitle: '你的专属 AI 助手', + welcomeDescription: 'Concierge 可以回答问题、更新支出等。', + viewChatHistory: '查看聊天记录', + hideChatHistory: '隐藏聊天记录', + askNewQuestion: '提出新问题', + newChat: '新聊天', + composerPlaceholder: '我上个月花了多少钱?', + quickActions: { + createExpense: '创建支出', + createExpensePrompt: '创建一笔支出', + analyzeSpend: '分析支出', + analyzeSpendPrompt: '我上个月花了多少钱?', + configureWorkspace: '配置工作区', + configureWorkspacePrompt: '帮我配置工作区', + getSupport: '获取支持', + getSupportPrompt: '我需要一些帮助', + }, + }, duplicateReport: '重复报销单', approver: '审批人', goToConcierge: '前往 Concierge', diff --git a/src/pages/inbox/ConciergeSessionContext.tsx b/src/pages/inbox/ConciergeSessionContext.tsx index 6122908e12ff..51e60a7be3a5 100644 --- a/src/pages/inbox/ConciergeSessionContext.tsx +++ b/src/pages/inbox/ConciergeSessionContext.tsx @@ -17,6 +17,8 @@ type ConciergeSessionStateContextType = { type ConciergeSessionActionsContextType = { startSession: (unreadBoundary?: string | null) => void; + /** Begins a brand new session, so the Concierge DM drops back to its welcome state */ + resetSession: () => void; setShowFullHistory: (show: boolean) => void; setHadMessagesAtSessionStart: (value: boolean) => void; }; @@ -29,6 +31,7 @@ const ConciergeSessionStateContext = createContext({ startSession: () => {}, + resetSession: () => {}, setShowFullHistory: () => {}, setHadMessagesAtSessionStart: () => {}, }); @@ -105,8 +108,15 @@ function ConciergeSessionProvider({children}: PropsWithChildren) { } }, []); + const resetSession = useCallback(() => { + sessionCreatedAtRef.current = Date.now(); + setSessionStartTime(getServerAnchoredDBTime()); + setShowFullHistory(false); + setHadMessagesAtSessionStart(false); + }, []); + const stateValue = useMemo(() => ({sessionStartTime, showFullHistory, hadMessagesAtSessionStart}), [sessionStartTime, showFullHistory, hadMessagesAtSessionStart]); - const actionsValue = useMemo(() => ({startSession, setShowFullHistory, setHadMessagesAtSessionStart}), [startSession]); + const actionsValue = useMemo(() => ({startSession, resetSession, setShowFullHistory, setHadMessagesAtSessionStart}), [startSession, resetSession]); return ( diff --git a/src/pages/inbox/HeaderView.tsx b/src/pages/inbox/HeaderView.tsx index 3b822f72ab62..ff734d5a963c 100644 --- a/src/pages/inbox/HeaderView.tsx +++ b/src/pages/inbox/HeaderView.tsx @@ -25,6 +25,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useParentReportAction from '@hooks/useParentReportAction'; +import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import {useDerivedReportNamesByReportIDs} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; @@ -34,6 +35,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import Navigation from '@libs/Navigation/Navigation'; import Parser from '@libs/Parser'; import {getPersonalDetailByEmail, getPersonalDetailsForAccountIDs} from '@libs/PersonalDetailsUtils'; import {getHumanAgentAccountIDFromReportAction, getHumanAgentFirstName} from '@libs/ReportActionsUtils'; @@ -86,6 +88,7 @@ import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -97,6 +100,8 @@ import {isPast} from 'date-fns'; import React, {useMemo} from 'react'; import {Keyboard, View} from 'react-native'; +import {useConciergeSessionActions} from './ConciergeSessionContext'; + type HeaderViewProps = { /** Toggles the navigationMenu open and closed */ onNavigationMenuButtonClicked: () => void; @@ -109,7 +114,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const parentReportAction = useParentReportAction(report); - const icons = useMemoizedLazyExpensifyIcons(['BackArrow', 'Close', 'DotIndicator']); + const icons = useMemoizedLazyExpensifyIcons(['BackArrow', 'Close', 'DotIndicator', 'Plus']); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, shouldUseNarrowLayout, isInLandscapeMode} = useResponsiveLayout(); const isInSidePanel = useIsInSidePanel(); @@ -145,6 +150,10 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const isSelfDM = isSelfDMReportUtils(report); const isGroupChat = isGroupChatReportUtils(report) || isDeprecatedGroupDM(report, isReportArchived); const isConciergeChat = isConciergeChatReport(report, conciergeReportID); + const {isBetaEnabled} = usePermissions(); + const {resetSession: resetConciergeSession} = useConciergeSessionActions(); + const isAskConciergeEnabled = isBetaEnabled(CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD) && !isInSidePanel; + const isConciergeThread = !!conciergeReportID && report?.parentReportID === conciergeReportID; const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [onboarding] = useOnyx(ONYXKEYS.NVP_ONBOARDING); const allParticipants = getParticipantsAccountIDsForDisplay(report, false, true, undefined, reportMetadata); @@ -174,7 +183,8 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); // Use sorted display names for the title for group chats on native small screen widths const title = getReportName(reportHeaderData, derivedReportHeaderName); - const subtitle = getChatRoomSubtitle(reportHeaderData, reportHeaderDataPolicy, conciergeReportID, translate, false, isReportHeaderDataArchived); + const chatRoomSubtitle = getChatRoomSubtitle(reportHeaderData, reportHeaderDataPolicy, conciergeReportID, translate, false, isReportHeaderDataArchived); + const subtitle = isAskConciergeEnabled && isConciergeChat ? translate('common.concierge.subtitle') : chatRoomSubtitle; // This is used to get the status badge for invoice report subtitle. const statusTextForInvoiceReport = isParentInvoiceAndIsChatThread ? getReportStatusTranslation({stateNum: reportHeaderData?.stateNum, statusNum: reportHeaderData?.statusNum, translate}) @@ -292,6 +302,21 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) ); + const startNewConciergeChat = () => { + resetConciergeSession(); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID)); + }; + + const newConciergeChatButton = ( + + ); + const renderAdditionalText = () => { if (shouldShowSubtitle() || isPersonalExpenseChat || !policyName || !isEmptyObject(parentNavigationSubtitleData) || isSelfDM) { return null; @@ -472,6 +497,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) )} + {isAskConciergeEnabled && isConciergeThread && newConciergeChatButton} {shouldShowBookCall && !shouldStackBookCall && bookCallButton} {shouldShowOnBoardingHelpDropdownButton && !shouldUseNarrowLayout && onboardingHelpDropdownButton} {!shouldUseNarrowLayout && !shouldShowDiscount && isChatUsedForOnboarding && ( diff --git a/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx b/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx new file mode 100644 index 000000000000..838185c6ae8a --- /dev/null +++ b/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx @@ -0,0 +1,62 @@ +import Button from '@components/ButtonComposed'; + +import useConciergeAskState from '@hooks/useConciergeAskState'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {useConciergeSessionActions} from '@pages/inbox/ConciergeSessionContext'; + +import CONST from '@src/CONST'; + +import React from 'react'; +import {View} from 'react-native'; + +import {useReportActionsListActions, useReportActionsListState} from './ReportActionsListContext'; + +type ConciergeChatHistoryToggleProps = { + /** The ID of the report being displayed */ + reportID: string; +}; + +/** + * Expands and collapses the earlier Concierge conversation in the main Concierge DM. It renders as the + * inverted list's header so it sits at the bottom of the conversation, directly above the composer. + */ +function ConciergeChatHistoryToggle({reportID}: ConciergeChatHistoryToggleProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['UpArrow', 'DownArrow']); + const {shouldShowWelcome, shouldLabelComposerAsNewQuestion} = useConciergeAskState(reportID); + const {showFullHistory, hasPreviousMessages} = useReportActionsListState(); + const {handleShowPreviousMessages} = useReportActionsListActions(); + const {setShowFullHistory} = useConciergeSessionActions(); + + const hideChatHistory = () => setShowFullHistory(false); + + // Both flags are false once the user asks something, which is when this control would otherwise + // land between the latest message and the composer. + if (!shouldShowWelcome && !shouldLabelComposerAsNewQuestion) { + return null; + } + + if (!hasPreviousMessages) { + return null; + } + + return ( + + + + + + ); +} + +export default ConciergeChatHistoryToggle; diff --git a/src/pages/inbox/report/ConciergeQuickActions.tsx b/src/pages/inbox/report/ConciergeQuickActions.tsx new file mode 100644 index 000000000000..0436d0704861 --- /dev/null +++ b/src/pages/inbox/report/ConciergeQuickActions.tsx @@ -0,0 +1,65 @@ +import Button from '@components/ButtonComposed'; + +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; + +import {saveReportDraftComment} from '@userActions/Report'; + +import CONST from '@src/CONST'; + +import React from 'react'; +import {View} from 'react-native'; + +const QUICK_ACTIONS = [ + {iconName: 'Plus', labelKey: 'common.concierge.quickActions.createExpense', promptKey: 'common.concierge.quickActions.createExpensePrompt'}, + {iconName: 'ChartPie', labelKey: 'common.concierge.quickActions.analyzeSpend', promptKey: 'common.concierge.quickActions.analyzeSpendPrompt'}, + {iconName: 'Wrench', labelKey: 'common.concierge.quickActions.configureWorkspace', promptKey: 'common.concierge.quickActions.configureWorkspacePrompt'}, + {iconName: 'QuestionMark', labelKey: 'common.concierge.quickActions.getSupport', promptKey: 'common.concierge.quickActions.getSupportPrompt'}, +] as const; + +const QUICK_ACTION_ICON_NAMES = QUICK_ACTIONS.map((action) => action.iconName); + +type ConciergeQuickActionsProps = { + /** The ID of the Concierge report the prompt is written into */ + reportID: string; +}; + +/** + * Conversation starters shown under the composer while the Concierge chat is still empty. Pressing one + * writes its prompt into the composer so the user can edit it before sending. + */ +function ConciergeQuickActions({reportID}: ConciergeQuickActionsProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(QUICK_ACTION_ICON_NAMES); + + return ( + + {QUICK_ACTIONS.map(({iconName, labelKey, promptKey}) => ( + + ))} + + ); +} + +export default ConciergeQuickActions; diff --git a/src/pages/inbox/report/ConciergeWelcome.tsx b/src/pages/inbox/report/ConciergeWelcome.tsx new file mode 100644 index 000000000000..b89e85844cc4 --- /dev/null +++ b/src/pages/inbox/report/ConciergeWelcome.tsx @@ -0,0 +1,27 @@ +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; + +import HomeSectionEmptyState from '@pages/home/HomeSectionEmptyState'; + +import React from 'react'; + +const ILLUSTRATION_NAMES = ['ConciergeBot'] as const; + +/** + * Empty state shown in the main Concierge DM before the user asks anything. + */ +function ConciergeWelcome() { + const {translate} = useLocalize(); + const illustrations = useMemoizedLazyIllustrations(ILLUSTRATION_NAMES); + + return ( + + ); +} + +export default ConciergeWelcome; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx index 777265b2b8a2..b464af77d9f1 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx @@ -3,6 +3,7 @@ import type {LocalizedTranslate} from '@components/LocaleContextProvider'; import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import useReportIsArchived from '@hooks/useReportIsArchived'; import {setIsComposerFullSize} from '@libs/actions/Report'; @@ -45,6 +46,7 @@ function ComposerInput() { const {setIsFullComposerAvailable, onBlur, onFocus, setComposerRef} = useComposerActions(); const {containerRef, suggestionsRef, isNextModalWillOpenRef} = useComposerMeta(); const {isEditingInComposer, didResetComposerHeightWhileEditing} = useComposerEditState(); + const {isBetaEnabled} = usePermissions(); const isSubmittingEdit = isEditingInComposer || didResetComposerHeightWhileEditing; const {submitDraftAndClearComposer, validateAndSubmitDraft} = useComposerSubmit(reportID); @@ -69,6 +71,8 @@ function ComposerInput() { const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const isReportArchived = useReportIsArchived(report?.reportID); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const isAskConciergeChat = !!reportID && reportID === conciergeReportID && isBetaEnabled(CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD); const includesConcierge = chatIncludesConcierge({participants: report?.participants}); const isGroupPolicyReport = !!report?.policyID && report.policyID !== CONST.POLICY.ID_FAKE; @@ -79,6 +83,8 @@ function ComposerInput() { let inputPlaceholder = translate('reportActionCompose.writeSomething'); if (includesConcierge && userBlockedFromConcierge) { inputPlaceholder = translate('reportActionCompose.blockedFromConcierge'); + } else if (isAskConciergeChat) { + inputPlaceholder = translate('common.concierge.composerPlaceholder'); } else if (isExpenseRelatedReport && canUserPerformWriteAction && isEnglishLocale) { inputPlaceholder = getRandomPlaceholder(translate); } diff --git a/src/pages/inbox/report/ReportActionItemCreated.tsx b/src/pages/inbox/report/ReportActionItemCreated.tsx index 42e6a02e8e43..98a1c2181eed 100644 --- a/src/pages/inbox/report/ReportActionItemCreated.tsx +++ b/src/pages/inbox/report/ReportActionItemCreated.tsx @@ -3,6 +3,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import ReportWelcomeText from '@components/ReportWelcomeText'; +import useConciergeAskState from '@hooks/useConciergeAskState'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useIsInSidePanel from '@hooks/useIsInSidePanel'; import useLocalize from '@hooks/useLocalize'; @@ -24,6 +25,7 @@ import React, {memo} from 'react'; import {View} from 'react-native'; import AnimatedEmptyStateBackground from './AnimatedEmptyStateBackground'; +import ConciergeWelcome from './ConciergeWelcome'; type ReportActionItemCreatedProps = { /** The id of the report */ @@ -55,11 +57,16 @@ function ReportActionItemCreated({reportID, policyID}: ReportActionItemCreatedPr .map(Number) .find((id) => id !== currentUserAccountID) ?? CONST.DEFAULT_NUMBER_ID; const [isParticipantOptimistic = true] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: isOptimisticPersonalDetailSelector(otherParticipantAccountID)}); + const {shouldShowWelcome} = useConciergeAskState(reportID); if (!isChatReport(report)) { return null; } + if (shouldShowWelcome) { + return ; + } + const shouldDisableDetailPage = shouldDisableDetailPageReportUtils(report, isParticipantOptimistic); return ( diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index a733aa6ed3d3..e06c13feb9a1 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -2,6 +2,7 @@ import {renderScrollComponent as renderActionSheetAwareScrollView} from '@compon import InvertedFlashList from '@components/FlashList/InvertedFlashList'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; +import useConciergeAskState from '@hooks/useConciergeAskState'; import useEnvironment from '@hooks/useEnvironment'; import useLinkedMessageOfflineLoading from '@hooks/useLinkedMessageOfflineLoading'; import useLocalize from '@hooks/useLocalize'; @@ -135,6 +136,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const route = useRoute>(); const reportActionIDFromRoute = route?.params?.reportActionID; const {sessionStartTime} = useConciergeSessionState(); + const {shouldShowWelcome: shouldShowConciergeWelcome} = useConciergeAskState(reportID); const didLayout = useRef(false); @@ -469,7 +471,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct keyExtractor={keyExtractor} drawDistance={1500} renderScrollComponent={renderActionSheetAwareScrollView} - contentContainerStyle={styles.chatContentScrollView} + contentContainerStyle={[styles.chatContentScrollView, shouldShowConciergeWelcome && styles.chatContentScrollViewCentered]} onEndReached={loadOlderChatsOnEndReached} onEndReachedThreshold={0.75} onStartReached={loadNewerChatsAfterTransitions} diff --git a/src/pages/inbox/report/ReportActionsListHeader.tsx b/src/pages/inbox/report/ReportActionsListHeader.tsx index 9929f339e7f9..f5bc66827674 100644 --- a/src/pages/inbox/report/ReportActionsListHeader.tsx +++ b/src/pages/inbox/report/ReportActionsListHeader.tsx @@ -2,6 +2,8 @@ import ConciergeThinkingMessage from '@pages/home/report/ConciergeThinkingMessag import React from 'react'; +import ConciergeChatHistoryToggle from './ConciergeChatHistoryToggle'; + type ReportActionsListHeaderProps = { /** The ID of the report being displayed */ reportID: string; @@ -11,11 +13,12 @@ type ReportActionsListHeaderProps = { }; function ReportActionsListHeader({reportID, isDraftPendingCompletion}: ReportActionsListHeaderProps) { - if (isDraftPendingCompletion) { - return null; - } - - return ; + return ( + <> + + {!isDraftPendingCompletion && } + + ); } export default ReportActionsListHeader; diff --git a/src/pages/inbox/report/ReportFooter.tsx b/src/pages/inbox/report/ReportFooter.tsx index 7d230e1f9128..1731dc74cd8b 100644 --- a/src/pages/inbox/report/ReportFooter.tsx +++ b/src/pages/inbox/report/ReportFooter.tsx @@ -4,7 +4,9 @@ import Banner from '@components/Banner'; import BlockedReportFooter from '@components/BlockedReportFooter'; import OfflineIndicator from '@components/OfflineIndicator'; import SwipeableView from '@components/SwipeableView'; +import Text from '@components/Text'; +import useConciergeAskState from '@hooks/useConciergeAskState'; import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; import useIsReportReadyToDisplay from '@hooks/useIsReportReadyToDisplay'; @@ -37,6 +39,7 @@ import {isBlockedFromChatSelector} from '@selectors/BlockedFromChat'; import React from 'react'; import {Keyboard, View} from 'react-native'; +import ConciergeQuickActions from './ConciergeQuickActions'; import EnableNotificationsBanner, {BANNER_COMPOSER_OVERLAP_PX} from './EnableNotificationsBanner'; import ReportActionCompose from './ReportActionCompose/ReportActionCompose'; import SystemChatReportFooterMessage from './SystemChatReportFooterMessage'; @@ -92,6 +95,7 @@ function ReportFooter() { const isSystemChat = isSystemChatUtil(report); const isAdminsOnlyPostingRoom = isAdminsOnlyPostingRoomUtil(report); const shouldShowComposerForActiveEditDraft = useShouldShowComposerForActiveEditDraft(); + const {shouldShowWelcome: shouldShowConciergeWelcome, shouldLabelComposerAsNewQuestion} = useConciergeAskState(reportIDFromRoute); if (!isCurrentReportLoadedFromOnyx || !report || !reportIDFromRoute) { return null; @@ -108,6 +112,7 @@ function ReportFooter() { ); return ( + {shouldLabelComposerAsNewQuestion && {translate('common.concierge.askNewQuestion')}} {shouldShowEnableNotificationsBanner ? ( <> @@ -116,6 +121,7 @@ function ReportFooter() { ) : ( composer )} + {shouldShowConciergeWelcome && } ); } diff --git a/src/pages/inbox/report/ShowPreviousMessagesButton.tsx b/src/pages/inbox/report/ShowPreviousMessagesButton.tsx index a79be7182b07..ab587d701a8e 100644 --- a/src/pages/inbox/report/ShowPreviousMessagesButton.tsx +++ b/src/pages/inbox/report/ShowPreviousMessagesButton.tsx @@ -3,6 +3,7 @@ import Button from '@components/ButtonComposed'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; @@ -33,12 +34,17 @@ function ShowPreviousMessagesButton({reportID, actionType, hasPreviousMessages, const styles = useThemeStyles(); const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['UpArrow']); + const {isBetaEnabled} = usePermissions(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const isConciergeChat = reportID === conciergeReportID; if (!isConciergeChat) { return null; } + // ConciergeChatHistoryToggle takes over in the Ask Concierge design, where the control also collapses the history again. + if (isBetaEnabled(CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD)) { + return null; + } if (!onPress) { return null; } @@ -60,7 +66,7 @@ function ShowPreviousMessagesButton({reportID, actionType, hasPreviousMessages, size={CONST.BUTTON_SIZE.SMALL} onPress={onPress} > - {translate('common.concierge.showHistory')} + {translate('common.concierge.viewChatHistory')} diff --git a/src/styles/index.ts b/src/styles/index.ts index 4af9fe456c0c..2b2ffa55bfe7 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -2242,6 +2242,11 @@ const staticStyles = (theme: ThemeColors) => ...chatContentScrollViewPlatformStyles, }, + // The list is inverted, so centering here lifts a short welcome screen off the composer and into the middle of the chat. + chatContentScrollViewCentered: { + justifyContent: 'center', + }, + // Chat Item chatItem: { display: 'flex', diff --git a/tests/unit/ShowPreviousMessagesButtonTest.tsx b/tests/unit/ShowPreviousMessagesButtonTest.tsx index 301da70b4d17..8203eee4caec 100644 --- a/tests/unit/ShowPreviousMessagesButtonTest.tsx +++ b/tests/unit/ShowPreviousMessagesButtonTest.tsx @@ -96,4 +96,15 @@ describe('ShowPreviousMessagesButton', () => { renderButton({showFullHistory: true}); expect(screen.queryByRole('button')).toBeNull(); }); + + it('renders nothing when the Ask Concierge beta is enabled', async () => { + await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD]); + await waitForBatchedUpdates(); + + renderButton(); + expect(screen.queryByRole('button')).toBeNull(); + + await Onyx.set(ONYXKEYS.BETAS, []); + await waitForBatchedUpdates(); + }); }); From f6bb4159d98e22f282f2e70c9f7774acd8d6db0c Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 7 Sep 2026 22:04:01 +0200 Subject: [PATCH 002/156] Fix CI failures --- assets/images/chart-pie.svg | 2 +- src/hooks/useConciergeAskState.ts | 1 - .../report/ConciergeChatHistoryToggle.tsx | 25 +++++++++++-------- src/pages/inbox/report/ReportActionsList.tsx | 17 ++++++++++--- .../inbox/report/ReportActionsListHeader.tsx | 13 ++++------ tests/ui/ReportActionsListTest.tsx | 8 +++--- 6 files changed, 38 insertions(+), 28 deletions(-) diff --git a/assets/images/chart-pie.svg b/assets/images/chart-pie.svg index f5099496e1e1..97da1ef66cef 100644 --- a/assets/images/chart-pie.svg +++ b/assets/images/chart-pie.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/src/hooks/useConciergeAskState.ts b/src/hooks/useConciergeAskState.ts index e2ed6d4ae3f1..9d41745a7caa 100644 --- a/src/hooks/useConciergeAskState.ts +++ b/src/hooks/useConciergeAskState.ts @@ -55,4 +55,3 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { } export default useConciergeAskState; -export type {ConciergeAskState}; diff --git a/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx b/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx index 838185c6ae8a..ee9f6469dbd6 100644 --- a/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx +++ b/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx @@ -12,24 +12,29 @@ import CONST from '@src/CONST'; import React from 'react'; import {View} from 'react-native'; -import {useReportActionsListActions, useReportActionsListState} from './ReportActionsListContext'; - type ConciergeChatHistoryToggleProps = { /** The ID of the report being displayed */ reportID: string; + + /** Whether there are messages hidden before the session start */ + hasPreviousMessages: boolean; + + /** Whether the earlier conversation is currently shown */ + shouldShowFullHistory: boolean; + + /** Callback to reveal the earlier conversation */ + onShowPreviousMessages: () => void; }; /** - * Expands and collapses the earlier Concierge conversation in the main Concierge DM. It renders as the - * inverted list's header so it sits at the bottom of the conversation, directly above the composer. + * Expands and collapses the earlier Concierge conversation in the main Concierge DM. It renders as part + * of the inverted list's header so it sits at the bottom of the conversation, directly above the composer. */ -function ConciergeChatHistoryToggle({reportID}: ConciergeChatHistoryToggleProps) { +function ConciergeChatHistoryToggle({reportID, hasPreviousMessages, shouldShowFullHistory, onShowPreviousMessages}: ConciergeChatHistoryToggleProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['UpArrow', 'DownArrow']); const {shouldShowWelcome, shouldLabelComposerAsNewQuestion} = useConciergeAskState(reportID); - const {showFullHistory, hasPreviousMessages} = useReportActionsListState(); - const {handleShowPreviousMessages} = useReportActionsListActions(); const {setShowFullHistory} = useConciergeSessionActions(); const hideChatHistory = () => setShowFullHistory(false); @@ -49,10 +54,10 @@ function ConciergeChatHistoryToggle({reportID}: ConciergeChatHistoryToggleProps) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index e06c13feb9a1..8c47616fd636 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -64,6 +64,7 @@ import {useRoute} from '@react-navigation/native'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import React, {useEffect, useRef, useState} from 'react'; +import ConciergeChatHistoryToggle from './ConciergeChatHistoryToggle'; import FloatingMessageCounter from './FloatingMessageCounter'; import ReportActionIndexContext from './ReportActionIndexContext'; import {useReportActionsListActions, useReportActionsListState} from './ReportActionsListContext'; @@ -409,10 +410,18 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct ]; const listHeaderComponent = ( - + <> + + + ); const shouldShowOfflineSkeleton = isOffline && !sortedVisibleReportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); diff --git a/src/pages/inbox/report/ReportActionsListHeader.tsx b/src/pages/inbox/report/ReportActionsListHeader.tsx index f5bc66827674..9929f339e7f9 100644 --- a/src/pages/inbox/report/ReportActionsListHeader.tsx +++ b/src/pages/inbox/report/ReportActionsListHeader.tsx @@ -2,8 +2,6 @@ import ConciergeThinkingMessage from '@pages/home/report/ConciergeThinkingMessag import React from 'react'; -import ConciergeChatHistoryToggle from './ConciergeChatHistoryToggle'; - type ReportActionsListHeaderProps = { /** The ID of the report being displayed */ reportID: string; @@ -13,12 +11,11 @@ type ReportActionsListHeaderProps = { }; function ReportActionsListHeader({reportID, isDraftPendingCompletion}: ReportActionsListHeaderProps) { - return ( - <> - - {!isDraftPendingCompletion && } - - ); + if (isDraftPendingCompletion) { + return null; + } + + return ; } export default ReportActionsListHeader; diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 0b1aac4cf61e..c68e081a6307 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -300,7 +300,7 @@ describe('ReportActionsList (body)', () => { revealDraftFromReportAction: jest.fn(), }); mockUseConciergeSessionState.mockReturnValue({sessionStartTime: null, showFullHistory: false, hadMessagesAtSessionStart: false}); - mockUseConciergeSessionActions.mockReturnValue({startSession: jest.fn(), setShowFullHistory: jest.fn(), setHadMessagesAtSessionStart: jest.fn()}); + mockUseConciergeSessionActions.mockReturnValue({startSession: jest.fn(), resetSession: jest.fn(), setShowFullHistory: jest.fn(), setHadMessagesAtSessionStart: jest.fn()}); mockUseOnyx.mockImplementation((key: string, options) => { // useReportActionsListModel derives app-load state from the request queue via useIsAppLoadPending, @@ -791,7 +791,7 @@ describe('ReportActionsList (body)', () => { mockUseIsInSidePanel.mockReturnValue(false); mockUseSidePanelState.mockReturnValue(defaultSidePanelState); mockUseConciergeSessionState.mockReturnValue({sessionStartTime, showFullHistory, hadMessagesAtSessionStart: false}); - mockUseConciergeSessionActions.mockReturnValue({startSession: jest.fn(), setShowFullHistory: jest.fn(), setHadMessagesAtSessionStart: jest.fn()}); + mockUseConciergeSessionActions.mockReturnValue({startSession: jest.fn(), resetSession: jest.fn(), setShowFullHistory: jest.fn(), setHadMessagesAtSessionStart: jest.fn()}); mockUseOnyx.mockImplementation((key: string, options) => { if (key === ONYXKEYS.CONCIERGE_REPORT_ID) { @@ -953,9 +953,9 @@ describe('ReportActionsList (body)', () => { it('should call startSession on mount for main DM concierge', () => { const mockStartSession = jest.fn(); - mockUseConciergeSessionActions.mockReturnValue({startSession: mockStartSession, setShowFullHistory: jest.fn(), setHadMessagesAtSessionStart: jest.fn()}); + mockUseConciergeSessionActions.mockReturnValue({startSession: mockStartSession, resetSession: jest.fn(), setShowFullHistory: jest.fn(), setHadMessagesAtSessionStart: jest.fn()}); setupMainDMConciergeMocks(); - mockUseConciergeSessionActions.mockReturnValue({startSession: mockStartSession, setShowFullHistory: jest.fn(), setHadMessagesAtSessionStart: jest.fn()}); + mockUseConciergeSessionActions.mockReturnValue({startSession: mockStartSession, resetSession: jest.fn(), setShowFullHistory: jest.fn(), setHadMessagesAtSessionStart: jest.fn()}); mockUsePaginatedReportActions.mockReturnValue({ ...defaultPaginatedReportActionsResult, From 60fe442a5a1bc720c65643a40868f287f103f4e4 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Sun, 13 Sep 2026 22:52:54 +0200 Subject: [PATCH 003/156] Fix Button import --- src/pages/inbox/report/ConciergeChatHistoryToggle.tsx | 2 +- src/pages/inbox/report/ConciergeQuickActions.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx b/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx index ee9f6469dbd6..64fdced35770 100644 --- a/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx +++ b/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import useConciergeAskState from '@hooks/useConciergeAskState'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; diff --git a/src/pages/inbox/report/ConciergeQuickActions.tsx b/src/pages/inbox/report/ConciergeQuickActions.tsx index 0436d0704861..19bba61c20a0 100644 --- a/src/pages/inbox/report/ConciergeQuickActions.tsx +++ b/src/pages/inbox/report/ConciergeQuickActions.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; From 95be9646710326164ea8eddee8af2c889a813ea9 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Sun, 13 Sep 2026 23:00:56 +0200 Subject: [PATCH 004/156] Remove quick actions --- .../Icon/chunks/expensify-icons.chunk.ts | 2 - src/hooks/useConciergeAskState.ts | 6 +- src/languages/de.ts | 10 --- src/languages/el.ts | 10 --- src/languages/en.ts | 10 --- src/languages/es.ts | 10 --- src/languages/fr.ts | 10 --- src/languages/it.ts | 10 --- src/languages/ja.ts | 10 --- src/languages/nl.ts | 10 --- src/languages/pl.ts | 10 --- src/languages/pt-BR.ts | 10 --- src/languages/zh-hans.ts | 10 --- .../inbox/report/ConciergeQuickActions.tsx | 65 ------------------- src/pages/inbox/report/ReportFooter.tsx | 4 +- 15 files changed, 4 insertions(+), 183 deletions(-) delete mode 100644 src/pages/inbox/report/ConciergeQuickActions.tsx diff --git a/src/components/Icon/chunks/expensify-icons.chunk.ts b/src/components/Icon/chunks/expensify-icons.chunk.ts index 9eaa9d290bc4..daafb8764fc9 100644 --- a/src/components/Icon/chunks/expensify-icons.chunk.ts +++ b/src/components/Icon/chunks/expensify-icons.chunk.ts @@ -44,7 +44,6 @@ import CarWithKey from '@assets/images/car-with-key.svg'; import Car from '@assets/images/car.svg'; import CaretUpDown from '@assets/images/caret-up-down.svg'; import Cash from '@assets/images/cash.svg'; -import ChartPie from '@assets/images/chart-pie.svg'; import ChatBubbleCounter from '@assets/images/chatbubble-counter.svg'; import ChatBubbleReply from '@assets/images/chatbubble-reply.svg'; import ChatBubbleUnread from '@assets/images/chatbubble-unread.svg'; @@ -341,7 +340,6 @@ const Expensicons = { CarPlus, Cash, CertiniaSquare, - ChartPie, ChatBubble, ChatBubbles, Checkbox, diff --git a/src/hooks/useConciergeAskState.ts b/src/hooks/useConciergeAskState.ts index 9d41745a7caa..2a30688b9bce 100644 --- a/src/hooks/useConciergeAskState.ts +++ b/src/hooks/useConciergeAskState.ts @@ -14,7 +14,7 @@ import useOnyx from './useOnyx'; import usePermissions from './usePermissions'; type ConciergeAskState = { - /** Whether to show the welcome empty state and the quick actions that go with it */ + /** Whether to show the welcome empty state */ shouldShowWelcome: boolean; /** Whether to label the composer as the start of a new question, which it is once earlier history is expanded */ @@ -23,8 +23,8 @@ type ConciergeAskState = { /** * Drives the Ask Concierge screen in the main Concierge DM. The empty state lives in the report actions - * list and the composer label and quick actions live in the report footer, and those are siblings, so - * both derive their state here rather than one passing it to the other. + * list and the composer label lives in the report footer, and those are siblings, so both derive their + * state here rather than one passing it to the other. */ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { const {isBetaEnabled} = usePermissions(); diff --git a/src/languages/de.ts b/src/languages/de.ts index 3794104ffde2..5aab364e9d6d 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -503,16 +503,6 @@ const translations: TranslationDeepObject = { askNewQuestion: 'Neue Frage stellen', newChat: 'Neuer Chat', composerPlaceholder: 'Wie viel habe ich letzten Monat ausgegeben?', - quickActions: { - createExpense: 'Ausgabe erstellen', - createExpensePrompt: 'Erstelle eine Ausgabe', - analyzeSpend: 'Ausgaben analysieren', - analyzeSpendPrompt: 'Wie viel habe ich letzten Monat ausgegeben?', - configureWorkspace: 'Arbeitsbereich einrichten', - configureWorkspacePrompt: 'Hilf mir, meinen Arbeitsbereich einzurichten', - getSupport: 'Support erhalten', - getSupportPrompt: 'Ich brauche bei etwas Hilfe', - }, }, duplicateReport: 'Duplizierten Bericht', approver: 'Genehmiger', diff --git a/src/languages/el.ts b/src/languages/el.ts index 784764dca393..84f90e58857d 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -505,16 +505,6 @@ const translations: TranslationDeepObject = { askNewQuestion: 'Κάντε μια νέα ερώτηση', newChat: 'Νέα συνομιλία', composerPlaceholder: 'Πόσα ξόδεψα τον προηγούμενο μήνα;', - quickActions: { - createExpense: 'Δημιουργία δαπάνης', - createExpensePrompt: 'Δημιούργησε μια δαπάνη', - analyzeSpend: 'Ανάλυση δαπανών', - analyzeSpendPrompt: 'Πόσα ξόδεψα τον προηγούμενο μήνα;', - configureWorkspace: 'Ρύθμιση χώρου εργασίας', - configureWorkspacePrompt: 'Βοήθησέ με να ρυθμίσω τον χώρο εργασίας μου', - getSupport: 'Λήψη υποστήριξης', - getSupportPrompt: 'Χρειάζομαι βοήθεια με κάτι', - }, }, vacationDelegate: 'Εκπρόσωπος διακοπών', expensifyLogo: 'Λογότυπο Expensify', diff --git a/src/languages/en.ts b/src/languages/en.ts index adc9da7c25e4..ecc3d2b3038c 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -550,16 +550,6 @@ const translations = { askNewQuestion: 'Ask a new question', newChat: 'New chat', composerPlaceholder: 'How much did I spend last month?', - quickActions: { - createExpense: 'Create expense', - createExpensePrompt: 'Create an expense', - analyzeSpend: 'Analyze spend', - analyzeSpendPrompt: 'How much did I spend last month?', - configureWorkspace: 'Configure workspace', - configureWorkspacePrompt: 'Help me configure my workspace', - getSupport: 'Get support', - getSupportPrompt: 'I need help with something', - }, }, vacationDelegate: 'Vacation delegate', expensifyLogo: 'Expensify logo', diff --git a/src/languages/es.ts b/src/languages/es.ts index 5049d3d331bc..38ef65b59b13 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -494,16 +494,6 @@ const translations: TranslationDeepObject = { askNewQuestion: 'Haz una nueva pregunta', newChat: 'Nuevo chat', composerPlaceholder: '¿Cuánto gasté el mes pasado?', - quickActions: { - createExpense: 'Crear gasto', - createExpensePrompt: 'Crea un gasto', - analyzeSpend: 'Analizar gastos', - analyzeSpendPrompt: '¿Cuánto gasté el mes pasado?', - configureWorkspace: 'Configurar espacio de trabajo', - configureWorkspacePrompt: 'Ayúdame a configurar mi espacio de trabajo', - getSupport: 'Obtener ayuda', - getSupportPrompt: 'Necesito ayuda con algo', - }, }, vacationDelegate: 'Delegado de vacaciones', expensifyLogo: 'Logo de Expensify', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 61bcf008dc7d..1f7ae8a06e07 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -503,16 +503,6 @@ const translations: TranslationDeepObject = { askNewQuestion: 'Poser une nouvelle question', newChat: 'Nouveau chat', composerPlaceholder: 'Combien ai-je dépensé le mois dernier ?', - quickActions: { - createExpense: 'Créer une dépense', - createExpensePrompt: 'Crée une dépense', - analyzeSpend: 'Analyser les dépenses', - analyzeSpendPrompt: 'Combien ai-je dépensé le mois dernier ?', - configureWorkspace: 'Configurer l’espace de travail', - configureWorkspacePrompt: 'Aide-moi à configurer mon espace de travail', - getSupport: 'Obtenir de l’aide', - getSupportPrompt: 'J’ai besoin d’aide pour quelque chose', - }, }, duplicateReport: 'Note de frais en double', approver: 'Approbateur', diff --git a/src/languages/it.ts b/src/languages/it.ts index 24ea28e0db8d..1762e06fde86 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -502,16 +502,6 @@ const translations: TranslationDeepObject = { askNewQuestion: 'Fai una nuova domanda', newChat: 'Nuova chat', composerPlaceholder: 'Quanto ho speso il mese scorso?', - quickActions: { - createExpense: 'Crea spesa', - createExpensePrompt: 'Crea una spesa', - analyzeSpend: 'Analizza le spese', - analyzeSpendPrompt: 'Quanto ho speso il mese scorso?', - configureWorkspace: 'Configura area di lavoro', - configureWorkspacePrompt: 'Aiutami a configurare la mia area di lavoro', - getSupport: 'Ottieni assistenza', - getSupportPrompt: 'Ho bisogno di aiuto con una cosa', - }, }, duplicateReport: 'Report duplicato', approver: 'Approvante', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index e9f3449af99a..188937636c9e 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -501,16 +501,6 @@ const translations: TranslationDeepObject = { askNewQuestion: '新しい質問をする', newChat: '新しいチャット', composerPlaceholder: '先月はいくら使いましたか?', - quickActions: { - createExpense: '経費を作成', - createExpensePrompt: '経費を作成して', - analyzeSpend: '支出を分析', - analyzeSpendPrompt: '先月はいくら使いましたか?', - configureWorkspace: 'ワークスペースを設定', - configureWorkspacePrompt: 'ワークスペースの設定を手伝って', - getSupport: 'サポートを受ける', - getSupportPrompt: '困っていることがあります', - }, }, duplicateReport: 'レポートを複製', approver: '承認者', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index e0a9a3e5c9b1..1f6741139e98 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -502,16 +502,6 @@ const translations: TranslationDeepObject = { askNewQuestion: 'Stel een nieuwe vraag', newChat: 'Nieuwe chat', composerPlaceholder: 'Hoeveel heb ik vorige maand uitgegeven?', - quickActions: { - createExpense: 'Uitgave maken', - createExpensePrompt: 'Maak een uitgave', - analyzeSpend: 'Uitgaven analyseren', - analyzeSpendPrompt: 'Hoeveel heb ik vorige maand uitgegeven?', - configureWorkspace: 'Werkruimte instellen', - configureWorkspacePrompt: 'Help me mijn werkruimte in te stellen', - getSupport: 'Hulp krijgen', - getSupportPrompt: 'Ik heb ergens hulp bij nodig', - }, }, duplicateReport: 'Dubbel rapport', approver: 'Fiatteur', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 1a13be1d3c25..a60c426ecec1 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -505,16 +505,6 @@ const translations: TranslationDeepObject = { askNewQuestion: 'Zadaj nowe pytanie', newChat: 'Nowy czat', composerPlaceholder: 'Ile wydałem w zeszłym miesiącu?', - quickActions: { - createExpense: 'Utwórz wydatek', - createExpensePrompt: 'Utwórz wydatek', - analyzeSpend: 'Analizuj wydatki', - analyzeSpendPrompt: 'Ile wydałem w zeszłym miesiącu?', - configureWorkspace: 'Skonfiguruj przestrzeń roboczą', - configureWorkspacePrompt: 'Pomóż mi skonfigurować moją przestrzeń roboczą', - getSupport: 'Uzyskaj pomoc', - getSupportPrompt: 'Potrzebuję z czymś pomocy', - }, }, duplicateReport: 'Zduplikowany raport', approver: 'Osoba zatwierdzająca', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 2477a9e5c593..809a8503cff3 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -501,16 +501,6 @@ const translations: TranslationDeepObject = { askNewQuestion: 'Faça uma nova pergunta', newChat: 'Novo chat', composerPlaceholder: 'Quanto gastei no mês passado?', - quickActions: { - createExpense: 'Criar despesa', - createExpensePrompt: 'Crie uma despesa', - analyzeSpend: 'Analisar gastos', - analyzeSpendPrompt: 'Quanto gastei no mês passado?', - configureWorkspace: 'Configurar espaço de trabalho', - configureWorkspacePrompt: 'Ajude-me a configurar meu espaço de trabalho', - getSupport: 'Obter suporte', - getSupportPrompt: 'Preciso de ajuda com uma coisa', - }, }, duplicateReport: 'Duplicar relatório', approver: 'Aprovador', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index a9ce96a394fa..f553122ade35 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -497,16 +497,6 @@ const translations: TranslationDeepObject = { askNewQuestion: '提出新问题', newChat: '新聊天', composerPlaceholder: '我上个月花了多少钱?', - quickActions: { - createExpense: '创建支出', - createExpensePrompt: '创建一笔支出', - analyzeSpend: '分析支出', - analyzeSpendPrompt: '我上个月花了多少钱?', - configureWorkspace: '配置工作区', - configureWorkspacePrompt: '帮我配置工作区', - getSupport: '获取支持', - getSupportPrompt: '我需要一些帮助', - }, }, duplicateReport: '重复报销单', approver: '审批人', diff --git a/src/pages/inbox/report/ConciergeQuickActions.tsx b/src/pages/inbox/report/ConciergeQuickActions.tsx deleted file mode 100644 index 19bba61c20a0..000000000000 --- a/src/pages/inbox/report/ConciergeQuickActions.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import Button from '@components/Button'; - -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; - -import {saveReportDraftComment} from '@userActions/Report'; - -import CONST from '@src/CONST'; - -import React from 'react'; -import {View} from 'react-native'; - -const QUICK_ACTIONS = [ - {iconName: 'Plus', labelKey: 'common.concierge.quickActions.createExpense', promptKey: 'common.concierge.quickActions.createExpensePrompt'}, - {iconName: 'ChartPie', labelKey: 'common.concierge.quickActions.analyzeSpend', promptKey: 'common.concierge.quickActions.analyzeSpendPrompt'}, - {iconName: 'Wrench', labelKey: 'common.concierge.quickActions.configureWorkspace', promptKey: 'common.concierge.quickActions.configureWorkspacePrompt'}, - {iconName: 'QuestionMark', labelKey: 'common.concierge.quickActions.getSupport', promptKey: 'common.concierge.quickActions.getSupportPrompt'}, -] as const; - -const QUICK_ACTION_ICON_NAMES = QUICK_ACTIONS.map((action) => action.iconName); - -type ConciergeQuickActionsProps = { - /** The ID of the Concierge report the prompt is written into */ - reportID: string; -}; - -/** - * Conversation starters shown under the composer while the Concierge chat is still empty. Pressing one - * writes its prompt into the composer so the user can edit it before sending. - */ -function ConciergeQuickActions({reportID}: ConciergeQuickActionsProps) { - const styles = useThemeStyles(); - const theme = useTheme(); - const {translate} = useLocalize(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(QUICK_ACTION_ICON_NAMES); - - return ( - - {QUICK_ACTIONS.map(({iconName, labelKey, promptKey}) => ( - - ))} - - ); -} - -export default ConciergeQuickActions; diff --git a/src/pages/inbox/report/ReportFooter.tsx b/src/pages/inbox/report/ReportFooter.tsx index d23c4367f824..d7744c2bb8ef 100644 --- a/src/pages/inbox/report/ReportFooter.tsx +++ b/src/pages/inbox/report/ReportFooter.tsx @@ -40,7 +40,6 @@ import {isBlockedFromChatSelector} from '@selectors/BlockedFromChat'; import React from 'react'; import {Keyboard, View} from 'react-native'; -import ConciergeQuickActions from './ConciergeQuickActions'; import EnableNotificationsBanner, {BANNER_COMPOSER_OVERLAP_PX} from './EnableNotificationsBanner'; import ReportActionCompose from './ReportActionCompose/ReportActionCompose'; import SystemChatReportFooterMessage from './SystemChatReportFooterMessage'; @@ -96,7 +95,7 @@ function ReportFooter() { const isSystemChat = isSystemChatUtil(report); const isAdminsOnlyPostingRoom = isAdminsOnlyPostingRoomUtil(report); const shouldShowComposerForActiveEditDraft = useShouldShowComposerForActiveEditDraft(); - const {shouldShowWelcome: shouldShowConciergeWelcome, shouldLabelComposerAsNewQuestion} = useConciergeAskState(reportIDFromRoute); + const {shouldLabelComposerAsNewQuestion} = useConciergeAskState(reportIDFromRoute); if (!isCurrentReportLoadedFromOnyx || !report || !reportIDFromRoute) { return null; @@ -133,7 +132,6 @@ function ReportFooter() { ) : ( composer )} - {shouldShowConciergeWelcome && } ); } From a6660300aa926caf49148fb0a3406fad8d324fc6 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Sun, 13 Sep 2026 23:17:15 +0200 Subject: [PATCH 005/156] Center welcome layout --- src/pages/inbox/ReportScreen.tsx | 4 +++- .../inbox/report/ReportActionItemCreated.tsx | 7 ------- src/pages/inbox/report/ReportActionsList.tsx | 18 +++++++++++++++++- src/styles/index.ts | 5 ----- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index 4658cc751d9d..1613511d9e58 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -3,6 +3,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import WideRHPOverlayWrapper from '@components/WideRHPOverlayWrapper'; +import useConciergeAskState from '@hooks/useConciergeAskState'; import {useCurrentReportIDState} from '@hooks/useCurrentReportID'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -83,6 +84,7 @@ function ReportScreen({route, navigation, shouldDeferReportActions = false}: Rep const screenWrapperStyle: ViewStyle[] = [styles.appContent, styles.flex1, {marginTop: viewportOffsetTop}]; const shouldDeferNonEssentials = useDeferNonEssentials(reportIDFromRoute); + const {shouldShowWelcome: shouldShowConciergeWelcome} = useConciergeAskState(reportIDFromRoute); useSubmitToDestinationVisible( [CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT, CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY], @@ -149,7 +151,7 @@ function ReportScreen({route, navigation, shouldDeferReportActions = false}: Rep id !== currentUserAccountID) ?? CONST.DEFAULT_NUMBER_ID; const [isParticipantOptimistic = true] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: isOptimisticPersonalDetailSelector(otherParticipantAccountID)}); - const {shouldShowWelcome} = useConciergeAskState(reportID); if (!isChatReport(report)) { return null; } - if (shouldShowWelcome) { - return ; - } - const shouldDisableDetailPage = shouldDisableDetailPageReportUtils(report, isParticipantOptimistic); return ( diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 5b7381385da2..a307773ebdfb 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -60,6 +60,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {ListRenderItemInfo} from '@shopify/flash-list'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; +import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useRoute} from '@react-navigation/native'; @@ -67,6 +68,7 @@ import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import React, {useEffect, useRef, useState} from 'react'; import ConciergeChatHistoryToggle from './ConciergeChatHistoryToggle'; +import ConciergeWelcome from './ConciergeWelcome'; import FloatingMessageCounter from './FloatingMessageCounter'; import ReportActionIndexContext from './ReportActionIndexContext'; import {useReportActionsListActions, useReportActionsListState} from './ReportActionsListContext'; @@ -460,6 +462,20 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct return ; } + if (shouldShowConciergeWelcome) { + return ( + + + + + ); + } + return ( <> {/* Pinned over the top of the list rather than laid out inside it, so scrolling the expense detail view @@ -493,7 +509,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct keyExtractor={keyExtractor} drawDistance={1500} renderScrollComponent={renderActionSheetAwareScrollView} - contentContainerStyle={[styles.chatContentScrollView, shouldShowConciergeWelcome && styles.chatContentScrollViewCentered]} + contentContainerStyle={styles.chatContentScrollView} onEndReached={loadOlderChatsOnEndReached} onEndReachedThreshold={0.75} onStartReached={loadNewerChatsAfterTransitions} diff --git a/src/styles/index.ts b/src/styles/index.ts index 4a271a631441..d8ba7a9de164 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -2250,11 +2250,6 @@ const staticStyles = (theme: ThemeColors) => ...chatContentScrollViewPlatformStyles, }, - // The list is inverted, so centering here lifts a short welcome screen off the composer and into the middle of the chat. - chatContentScrollViewCentered: { - justifyContent: 'center', - }, - // Chat Item chatItem: { display: 'flex', From 7329000be89ec0d429a33945b3d08e40b94f989e Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Sun, 13 Sep 2026 23:25:39 +0200 Subject: [PATCH 006/156] Responsive column width --- .../inbox/report/ConciergeChatHistoryToggle.tsx | 13 +++++++++---- src/pages/inbox/report/ReportActionsList.tsx | 3 ++- src/pages/inbox/report/ReportFooter.tsx | 4 ++-- src/styles/index.ts | 6 ++++++ src/styles/variables.ts | 1 + 5 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx b/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx index 64fdced35770..244f7436f166 100644 --- a/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx +++ b/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx @@ -9,6 +9,8 @@ import {useConciergeSessionActions} from '@pages/inbox/ConciergeSessionContext'; import CONST from '@src/CONST'; +import type {StyleProp, ViewStyle} from 'react-native'; + import React from 'react'; import {View} from 'react-native'; @@ -24,13 +26,16 @@ type ConciergeChatHistoryToggleProps = { /** Callback to reveal the earlier conversation */ onShowPreviousMessages: () => void; + + /** Styles applied to the row wrapping the divider and the button */ + containerStyles?: StyleProp; }; /** - * Expands and collapses the earlier Concierge conversation in the main Concierge DM. It renders as part - * of the inverted list's header so it sits at the bottom of the conversation, directly above the composer. + * Expands and collapses the earlier Concierge conversation in the main Concierge DM. It sits directly + * above the composer, either in the welcome column or in the inverted list's header. */ -function ConciergeChatHistoryToggle({reportID, hasPreviousMessages, shouldShowFullHistory, onShowPreviousMessages}: ConciergeChatHistoryToggleProps) { +function ConciergeChatHistoryToggle({reportID, hasPreviousMessages, shouldShowFullHistory, onShowPreviousMessages, containerStyles}: ConciergeChatHistoryToggleProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['UpArrow', 'DownArrow']); @@ -50,7 +55,7 @@ function ConciergeChatHistoryToggle({reportID, hasPreviousMessages, shouldShowFu } return ( - + diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 265d98ed1b6d..053b7f43d9da 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -420,7 +420,6 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct From 405db8633514c70df349f2f2239a313dd2a90a32 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 14 Sep 2026 00:08:00 +0200 Subject: [PATCH 015/156] Space welcome divider --- src/pages/inbox/report/ReportActionsList.tsx | 2 +- src/styles/index.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 053b7f43d9da..d8009d784c1a 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -469,7 +469,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct reportID={reportID} hasPreviousMessages={!!hasPreviousMessages} onShowPreviousMessages={onShowPreviousMessages} - containerStyles={styles.pb5} + containerStyles={styles.pv5} /> ); diff --git a/src/styles/index.ts b/src/styles/index.ts index 5c4797450b96..dd834365b091 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -2387,7 +2387,6 @@ const staticStyles = (theme: ThemeColors) => conciergeWelcomeContainer: { alignItems: 'center', gap: 12, - marginBottom: 52, }, conciergeWelcomeTitle: { From 6a5df9b179004bbd3f797ba93b97a3e8c213dc2e Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 14 Sep 2026 00:11:57 +0200 Subject: [PATCH 016/156] Fix session race --- src/pages/inbox/ConciergeSessionContext.tsx | 45 +++++++++------------ 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/src/pages/inbox/ConciergeSessionContext.tsx b/src/pages/inbox/ConciergeSessionContext.tsx index 51e60a7be3a5..cfa995ab0ace 100644 --- a/src/pages/inbox/ConciergeSessionContext.tsx +++ b/src/pages/inbox/ConciergeSessionContext.tsx @@ -70,6 +70,7 @@ function ConciergeSessionProvider({children}: PropsWithChildren) { if (prevAccountID !== accountID) { setPrevAccountID(accountID); if (sessionStartTime !== null) { + sessionCreatedAtRef.current = null; setSessionStartTime(null); setShowFullHistory(false); setHadMessagesAtSessionStart(false); @@ -77,35 +78,25 @@ function ConciergeSessionProvider({children}: PropsWithChildren) { } const startSession = useCallback((unreadBoundary?: string | null) => { - let sessionExpired = false; - setSessionStartTime((prev) => { - if (prev && sessionCreatedAtRef.current) { - const elapsed = Date.now() - sessionCreatedAtRef.current; - if (elapsed < CONST.CONCIERGE_SESSION_EXPIRATION_MS) { - // Within an active session, keep the existing boundary unless a better - // (earlier) unread boundary resolves after the session was created. On a - // cold open the session can lock to `now` before the unread anchor resolves; - // when it arrives we pull sessionStartTime back so the notification message - // isn't hidden behind "Show full history". The session age (sessionCreatedAtRef) - // is unchanged — only the display boundary is refined. - if (unreadBoundary && unreadBoundary < prev) { - return unreadBoundary; - } - return prev; - } - sessionExpired = true; + const sessionCreatedAt = sessionCreatedAtRef.current; + if (sessionCreatedAt !== null && Date.now() - sessionCreatedAt < CONST.CONCIERGE_SESSION_EXPIRATION_MS) { + // Within an active session, keep the existing boundary unless a better + // (earlier) unread boundary resolves after the session was created. On a + // cold open the session can lock to `now` before the unread anchor resolves; + // when it arrives we pull sessionStartTime back so the notification message + // isn't hidden behind "Show full history". The session age (sessionCreatedAtRef) + // is unchanged — only the display boundary is refined. + if (unreadBoundary) { + setSessionStartTime((prev) => (prev && unreadBoundary < prev ? unreadBoundary : prev)); } - sessionCreatedAtRef.current = Date.now(); - const now = getServerAnchoredDBTime(); - if (unreadBoundary && unreadBoundary < now) { - return unreadBoundary; - } - return now; - }); - if (sessionExpired) { - setShowFullHistory(false); - setHadMessagesAtSessionStart(false); + return; } + + const now = getServerAnchoredDBTime(); + sessionCreatedAtRef.current = Date.now(); + setSessionStartTime(unreadBoundary && unreadBoundary < now ? unreadBoundary : now); + setShowFullHistory(false); + setHadMessagesAtSessionStart(false); }, []); const resetSession = useCallback(() => { From ea11c459ef414e81fb02ba72c9c7370e70c32118 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 14 Sep 2026 04:34:09 +0200 Subject: [PATCH 017/156] Add placeholder rotation --- src/languages/de.ts | 16 +++++++++++++++ src/languages/el.ts | 16 +++++++++++++++ src/languages/en.ts | 16 +++++++++++++++ src/languages/es.ts | 16 +++++++++++++++ src/languages/fr.ts | 16 +++++++++++++++ src/languages/it.ts | 16 +++++++++++++++ src/languages/ja.ts | 16 +++++++++++++++ src/languages/nl.ts | 16 +++++++++++++++ src/languages/pl.ts | 16 +++++++++++++++ src/languages/pt-BR.ts | 16 +++++++++++++++ src/languages/zh-hans.ts | 16 +++++++++++++++ .../ReportActionCompose/ComposerInput.tsx | 20 ++++++++++++++++++- 12 files changed, 195 insertions(+), 1 deletion(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index bffdcecaa133..f84a261dd611 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -502,6 +502,22 @@ const translations: TranslationDeepObject = { hideChatHistory: 'Chatverlauf ausblenden', askNewQuestion: 'Neue Frage stellen', newChat: 'Neuer Chat', + composerPlaceholders: { + spendLastMonth: 'Wie viel habe ich letzten Monat ausgegeben?', + topExpenses: 'Zeig mir meine größten Ausgaben in diesem Monat', + spendDrivers: 'Was hat meine Ausgaben im letzten Quartal getrieben?', + travelSpendChange: 'Wie haben sich meine Reisekosten seit letztem Monat verändert?', + topSpender: 'Wer hat diesen Monat am meisten ausgegeben?', + missingCategory: 'Welche Ausgaben haben keine Kategorie?', + awaitingApproval: 'Welche Berichte warten auf meine Genehmigung?', + notReimbursed: 'Was wurde mir noch nicht erstattet?', + unsubmitted: 'Welche meiner Ausgaben sind noch nicht eingereicht?', + leftToPay: 'Was ist diesen Monat noch zu zahlen?', + createExpense: 'Erstelle eine Ausgabe über 30 $ für ein Mittagessen', + combineExpenses: 'Fasse alle meine offenen Ausgaben in einem Bericht zusammen', + exportPdf: 'Exportiere meinen letzten Bericht als PDF', + exportCsv: 'Gib mir eine CSV meiner Ausgaben über 50 $', + }, }, duplicateReport: 'Duplizierten Bericht', approver: 'Genehmiger', diff --git a/src/languages/el.ts b/src/languages/el.ts index 5ae962e62fcc..05ba6854af2e 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -504,6 +504,22 @@ const translations: TranslationDeepObject = { hideChatHistory: 'Απόκρυψη ιστορικού συνομιλιών', askNewQuestion: 'Κάντε μια νέα ερώτηση', newChat: 'Νέα συνομιλία', + composerPlaceholders: { + spendLastMonth: 'Πόσα ξόδεψα τον προηγούμενο μήνα;', + topExpenses: 'Δείξε μου τις μεγαλύτερες δαπάνες μου αυτόν τον μήνα', + spendDrivers: 'Τι οδήγησε τις δαπάνες μου το προηγούμενο τρίμηνο;', + travelSpendChange: 'Πώς άλλαξαν οι δαπάνες ταξιδιού μου από τον προηγούμενο μήνα;', + topSpender: 'Ποιος ξόδεψε τα περισσότερα αυτόν τον μήνα;', + missingCategory: 'Ποιες δαπάνες δεν έχουν κατηγορία;', + awaitingApproval: 'Ποιες αναφορές περιμένουν την έγκρισή μου;', + notReimbursed: 'Τι δεν μου έχει αποζημιωθεί ακόμη;', + unsubmitted: 'Ποιες από τις δαπάνες μου δεν έχουν υποβληθεί ακόμη;', + leftToPay: 'Τι μένει να πληρωθεί αυτόν τον μήνα;', + createExpense: 'Δημιούργησε μια δαπάνη 30 $ για ένα γεύμα', + combineExpenses: 'Συνδύασε όλες τις ανοιχτές δαπάνες μου σε μία αναφορά', + exportPdf: 'Εξήγαγε την τελευταία μου αναφορά σε PDF', + exportCsv: 'Δώσε μου ένα CSV με τις δαπάνες μου άνω των 50 $', + }, }, vacationDelegate: 'Εκπρόσωπος διακοπών', expensifyLogo: 'Λογότυπο Expensify', diff --git a/src/languages/en.ts b/src/languages/en.ts index 2e37eeb00e16..83b582035648 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -549,6 +549,22 @@ const translations = { hideChatHistory: 'Hide chat history', askNewQuestion: 'Ask a new question', newChat: 'New chat', + composerPlaceholders: { + spendLastMonth: 'How much did I spend last month?', + topExpenses: 'Show me my top expenses this month', + spendDrivers: 'What drove my spend last quarter?', + travelSpendChange: 'How did my travel spend change from last month?', + topSpender: 'Who spent the most this month?', + missingCategory: 'Which expenses are missing a category?', + awaitingApproval: 'Which reports are waiting on my approval?', + notReimbursed: "What haven't I been reimbursed yet?", + unsubmitted: 'Which of my expenses are still unsubmitted?', + leftToPay: "What's left to pay this month?", + createExpense: 'Create an expense for a $30 lunch', + combineExpenses: 'Combine all my open expenses into one report', + exportPdf: 'Export my last report to PDF', + exportCsv: 'Give me a CSV of my expenses over $50', + }, }, vacationDelegate: 'Vacation delegate', expensifyLogo: 'Expensify logo', diff --git a/src/languages/es.ts b/src/languages/es.ts index bff0daabe269..98b63b1c0429 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -493,6 +493,22 @@ const translations: TranslationDeepObject = { hideChatHistory: 'Ocultar historial del chat', askNewQuestion: 'Haz una nueva pregunta', newChat: 'Nuevo chat', + composerPlaceholders: { + spendLastMonth: '¿Cuánto gasté el mes pasado?', + topExpenses: 'Muéstrame mis mayores gastos de este mes', + spendDrivers: '¿Qué impulsó mi gasto el trimestre pasado?', + travelSpendChange: '¿Cómo cambió mi gasto en viajes desde el mes pasado?', + topSpender: '¿Quién gastó más este mes?', + missingCategory: '¿Qué gastos no tienen categoría?', + awaitingApproval: '¿Qué informes están esperando mi aprobación?', + notReimbursed: '¿Qué no se me ha reembolsado todavía?', + unsubmitted: '¿Cuáles de mis gastos siguen sin enviarse?', + leftToPay: '¿Qué queda por pagar este mes?', + createExpense: 'Crea un gasto de $30 por un almuerzo', + combineExpenses: 'Combina todos mis gastos abiertos en un informe', + exportPdf: 'Exporta mi último informe a PDF', + exportCsv: 'Dame un CSV de mis gastos de más de $50', + }, }, vacationDelegate: 'Delegado de vacaciones', expensifyLogo: 'Logo de Expensify', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 2d66934b0f13..adbfd44e6967 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -502,6 +502,22 @@ const translations: TranslationDeepObject = { hideChatHistory: 'Masquer l’historique du chat', askNewQuestion: 'Poser une nouvelle question', newChat: 'Nouveau chat', + composerPlaceholders: { + spendLastMonth: 'Combien ai-je dépensé le mois dernier ?', + topExpenses: 'Montre-moi mes plus grosses dépenses ce mois-ci', + spendDrivers: "Qu'est-ce qui a fait augmenter mes dépenses le trimestre dernier ?", + travelSpendChange: 'Comment mes dépenses de voyage ont-elles évolué depuis le mois dernier ?', + topSpender: 'Qui a le plus dépensé ce mois-ci ?', + missingCategory: "Quelles dépenses n'ont pas de catégorie ?", + awaitingApproval: 'Quels rapports attendent mon approbation ?', + notReimbursed: "Qu'est-ce qui ne m'a pas encore été remboursé ?", + unsubmitted: 'Lesquelles de mes dépenses ne sont pas encore soumises ?', + leftToPay: 'Que reste-t-il à payer ce mois-ci ?', + createExpense: 'Crée une dépense de 30 $ pour un déjeuner', + combineExpenses: 'Regroupe toutes mes dépenses ouvertes dans un seul rapport', + exportPdf: 'Exporte mon dernier rapport en PDF', + exportCsv: 'Donne-moi un CSV de mes dépenses de plus de 50 $', + }, }, duplicateReport: 'Note de frais en double', approver: 'Approbateur', diff --git a/src/languages/it.ts b/src/languages/it.ts index b2ba09c0c0cb..92b95d97da96 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -501,6 +501,22 @@ const translations: TranslationDeepObject = { hideChatHistory: 'Nascondi cronologia chat', askNewQuestion: 'Fai una nuova domanda', newChat: 'Nuova chat', + composerPlaceholders: { + spendLastMonth: 'Quanto ho speso il mese scorso?', + topExpenses: 'Mostrami le mie spese più alte di questo mese', + spendDrivers: 'Cosa ha determinato le mie spese lo scorso trimestre?', + travelSpendChange: 'Come sono cambiate le mie spese di viaggio rispetto al mese scorso?', + topSpender: 'Chi ha speso di più questo mese?', + missingCategory: 'Quali spese non hanno una categoria?', + awaitingApproval: 'Quali report sono in attesa della mia approvazione?', + notReimbursed: 'Cosa non mi è ancora stato rimborsato?', + unsubmitted: 'Quali delle mie spese non sono ancora state inviate?', + leftToPay: 'Cosa resta da pagare questo mese?', + createExpense: 'Crea una spesa di 30 $ per un pranzo', + combineExpenses: 'Unisci tutte le mie spese aperte in un unico report', + exportPdf: 'Esporta il mio ultimo report in PDF', + exportCsv: 'Dammi un CSV delle mie spese superiori a 50 $', + }, }, duplicateReport: 'Report duplicato', approver: 'Approvante', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 457736fd563c..2d943679cf77 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -500,6 +500,22 @@ const translations: TranslationDeepObject = { hideChatHistory: 'チャット履歴を非表示', askNewQuestion: '新しい質問をする', newChat: '新しいチャット', + composerPlaceholders: { + spendLastMonth: '先月はいくら使いましたか?', + topExpenses: '今月の支出が多い経費を見せて', + spendDrivers: '前四半期の支出の要因は何ですか?', + travelSpendChange: '先月から出張費はどう変わりましたか?', + topSpender: '今月いちばん使ったのは誰ですか?', + missingCategory: 'カテゴリーが設定されていない経費はどれですか?', + awaitingApproval: '承認待ちのレポートはどれですか?', + notReimbursed: 'まだ精算されていないものは何ですか?', + unsubmitted: 'まだ提出していない経費はどれですか?', + leftToPay: '今月の未払い分はいくらですか?', + createExpense: 'ランチの30ドルの経費を作成して', + combineExpenses: '未処理の経費をすべて1つのレポートにまとめて', + exportPdf: '直近のレポートをPDFでエクスポートして', + exportCsv: '50ドル以上の経費のCSVをください', + }, }, duplicateReport: 'レポートを複製', approver: '承認者', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 9529e7a714a3..f78789291da9 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -501,6 +501,22 @@ const translations: TranslationDeepObject = { hideChatHistory: 'Chatgeschiedenis verbergen', askNewQuestion: 'Stel een nieuwe vraag', newChat: 'Nieuwe chat', + composerPlaceholders: { + spendLastMonth: 'Hoeveel heb ik vorige maand uitgegeven?', + topExpenses: 'Laat mijn grootste uitgaven van deze maand zien', + spendDrivers: 'Waardoor stegen mijn uitgaven vorig kwartaal?', + travelSpendChange: 'Hoe zijn mijn reiskosten veranderd sinds vorige maand?', + topSpender: 'Wie heeft deze maand het meest uitgegeven?', + missingCategory: 'Welke uitgaven hebben geen categorie?', + awaitingApproval: 'Welke rapporten wachten op mijn goedkeuring?', + notReimbursed: 'Wat is nog niet aan mij vergoed?', + unsubmitted: 'Welke van mijn uitgaven zijn nog niet ingediend?', + leftToPay: 'Wat moet er deze maand nog betaald worden?', + createExpense: 'Maak een uitgave van $30 voor een lunch', + combineExpenses: 'Voeg al mijn open uitgaven samen in één rapport', + exportPdf: 'Exporteer mijn laatste rapport naar PDF', + exportCsv: 'Geef me een CSV van mijn uitgaven boven $50', + }, }, duplicateReport: 'Dubbel rapport', approver: 'Fiatteur', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 8cfa04c4bdca..cdebd0b41ea2 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -504,6 +504,22 @@ const translations: TranslationDeepObject = { hideChatHistory: 'Ukryj historię czatu', askNewQuestion: 'Zadaj nowe pytanie', newChat: 'Nowy czat', + composerPlaceholders: { + spendLastMonth: 'Ile wydałem w zeszłym miesiącu?', + topExpenses: 'Pokaż moje największe wydatki w tym miesiącu', + spendDrivers: 'Co napędzało moje wydatki w zeszłym kwartale?', + travelSpendChange: 'Jak zmieniły się moje wydatki na podróże od zeszłego miesiąca?', + topSpender: 'Kto wydał najwięcej w tym miesiącu?', + missingCategory: 'Które wydatki nie mają kategorii?', + awaitingApproval: 'Które raporty czekają na moje zatwierdzenie?', + notReimbursed: 'Co nie zostało mi jeszcze zwrócone?', + unsubmitted: 'Które z moich wydatków nie zostały jeszcze wysłane?', + leftToPay: 'Co pozostało do zapłaty w tym miesiącu?', + createExpense: 'Utwórz wydatek 30 $ na lunch', + combineExpenses: 'Połącz wszystkie moje otwarte wydatki w jeden raport', + exportPdf: 'Wyeksportuj mój ostatni raport do PDF', + exportCsv: 'Daj mi plik CSV z moimi wydatkami powyżej 50 $', + }, }, duplicateReport: 'Zduplikowany raport', approver: 'Osoba zatwierdzająca', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index e38a5f453b42..c7c9ef3e903d 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -500,6 +500,22 @@ const translations: TranslationDeepObject = { hideChatHistory: 'Ocultar histórico do chat', askNewQuestion: 'Faça uma nova pergunta', newChat: 'Novo chat', + composerPlaceholders: { + spendLastMonth: 'Quanto gastei no mês passado?', + topExpenses: 'Mostre minhas maiores despesas deste mês', + spendDrivers: 'O que impulsionou meus gastos no último trimestre?', + travelSpendChange: 'Como meus gastos com viagens mudaram desde o mês passado?', + topSpender: 'Quem gastou mais neste mês?', + missingCategory: 'Quais despesas estão sem categoria?', + awaitingApproval: 'Quais relatórios estão aguardando minha aprovação?', + notReimbursed: 'O que ainda não foi reembolsado para mim?', + unsubmitted: 'Quais das minhas despesas ainda não foram enviadas?', + leftToPay: 'O que falta pagar neste mês?', + createExpense: 'Crie uma despesa de US$ 30 para um almoço', + combineExpenses: 'Combine todas as minhas despesas abertas em um relatório', + exportPdf: 'Exporte meu último relatório em PDF', + exportCsv: 'Me dê um CSV das minhas despesas acima de US$ 50', + }, }, duplicateReport: 'Duplicar relatório', approver: 'Aprovador', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index c3c71e0df3e1..e0860f066261 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -496,6 +496,22 @@ const translations: TranslationDeepObject = { hideChatHistory: '隐藏聊天记录', askNewQuestion: '提出新问题', newChat: '新聊天', + composerPlaceholders: { + spendLastMonth: '我上个月花了多少钱?', + topExpenses: '显示我本月金额最高的支出', + spendDrivers: '上个季度我的支出主要来自哪里?', + travelSpendChange: '我的差旅支出与上个月相比有什么变化?', + topSpender: '本月谁花得最多?', + missingCategory: '哪些支出没有类别?', + awaitingApproval: '哪些报告在等待我审批?', + notReimbursed: '还有哪些费用没有报销给我?', + unsubmitted: '我的哪些支出还没有提交?', + leftToPay: '本月还有多少待付款?', + createExpense: '创建一笔 30 美元的午餐支出', + combineExpenses: '把我所有未处理的支出合并到一个报告中', + exportPdf: '将我最近的报告导出为 PDF', + exportCsv: '给我一份超过 50 美元的支出 CSV', + }, }, duplicateReport: '重复报销单', approver: '审批人', diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx index 8102ba98837b..d68000f9189e 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx @@ -32,6 +32,24 @@ import useComposerSubmit from './useComposerSubmit'; const AI_PLACEHOLDER_KEYS = ['reportActionCompose.askConciergeToUpdate', 'reportActionCompose.askConciergeToCorrect', 'reportActionCompose.askConciergeForHelp'] as const; +const ASK_CONCIERGE_PLACEHOLDER_KEYS = [ + 'common.concierge.composerPlaceholders.spendLastMonth', + 'common.concierge.composerPlaceholders.topExpenses', + 'common.concierge.composerPlaceholders.spendDrivers', + 'common.concierge.composerPlaceholders.travelSpendChange', + 'common.concierge.composerPlaceholders.topSpender', + 'common.concierge.composerPlaceholders.missingCategory', + 'common.concierge.composerPlaceholders.awaitingApproval', + 'common.concierge.composerPlaceholders.notReimbursed', + 'common.concierge.composerPlaceholders.unsubmitted', + 'common.concierge.composerPlaceholders.leftToPay', + 'common.concierge.composerPlaceholders.createExpense', + 'common.concierge.composerPlaceholders.combineExpenses', + 'common.concierge.composerPlaceholders.exportPdf', + 'common.concierge.composerPlaceholders.exportCsv', + 'reportActionCompose.askConciergeForHelp', +] as const; + function getRandomPlaceholder(translate: LocalizedTranslate): string { const randomIndex = Math.floor(Math.random() * AI_PLACEHOLDER_KEYS.length); return translate(AI_PLACEHOLDER_KEYS[randomIndex]); @@ -70,7 +88,7 @@ function ComposerInput() { const isReportArchived = useReportIsArchived(report?.reportID); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const isAskConciergeChat = !!reportID && reportID === conciergeReportID && isBetaEnabled(CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD); - const askConciergePlaceholderKey = useRef(AI_PLACEHOLDER_KEYS[Math.floor(Math.random() * AI_PLACEHOLDER_KEYS.length)]).current; + const askConciergePlaceholderKey = useRef(ASK_CONCIERGE_PLACEHOLDER_KEYS[Math.floor(Math.random() * ASK_CONCIERGE_PLACEHOLDER_KEYS.length)]).current; const includesConcierge = chatIncludesConcierge({participants: report?.participants}); const isGroupPolicyReport = !!report?.policyID && report.policyID !== CONST.POLICY.ID_FAKE; From eca32a2b9c9cb8cdf64f2779db9db47480d6982a Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 14 Sep 2026 04:37:15 +0200 Subject: [PATCH 018/156] Revert "Fix session race" This reverts commit 6a5df9b179004bbd3f797ba93b97a3e8c213dc2e. --- src/pages/inbox/ConciergeSessionContext.tsx | 45 ++++++++++++--------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/src/pages/inbox/ConciergeSessionContext.tsx b/src/pages/inbox/ConciergeSessionContext.tsx index cfa995ab0ace..51e60a7be3a5 100644 --- a/src/pages/inbox/ConciergeSessionContext.tsx +++ b/src/pages/inbox/ConciergeSessionContext.tsx @@ -70,7 +70,6 @@ function ConciergeSessionProvider({children}: PropsWithChildren) { if (prevAccountID !== accountID) { setPrevAccountID(accountID); if (sessionStartTime !== null) { - sessionCreatedAtRef.current = null; setSessionStartTime(null); setShowFullHistory(false); setHadMessagesAtSessionStart(false); @@ -78,25 +77,35 @@ function ConciergeSessionProvider({children}: PropsWithChildren) { } const startSession = useCallback((unreadBoundary?: string | null) => { - const sessionCreatedAt = sessionCreatedAtRef.current; - if (sessionCreatedAt !== null && Date.now() - sessionCreatedAt < CONST.CONCIERGE_SESSION_EXPIRATION_MS) { - // Within an active session, keep the existing boundary unless a better - // (earlier) unread boundary resolves after the session was created. On a - // cold open the session can lock to `now` before the unread anchor resolves; - // when it arrives we pull sessionStartTime back so the notification message - // isn't hidden behind "Show full history". The session age (sessionCreatedAtRef) - // is unchanged — only the display boundary is refined. - if (unreadBoundary) { - setSessionStartTime((prev) => (prev && unreadBoundary < prev ? unreadBoundary : prev)); + let sessionExpired = false; + setSessionStartTime((prev) => { + if (prev && sessionCreatedAtRef.current) { + const elapsed = Date.now() - sessionCreatedAtRef.current; + if (elapsed < CONST.CONCIERGE_SESSION_EXPIRATION_MS) { + // Within an active session, keep the existing boundary unless a better + // (earlier) unread boundary resolves after the session was created. On a + // cold open the session can lock to `now` before the unread anchor resolves; + // when it arrives we pull sessionStartTime back so the notification message + // isn't hidden behind "Show full history". The session age (sessionCreatedAtRef) + // is unchanged — only the display boundary is refined. + if (unreadBoundary && unreadBoundary < prev) { + return unreadBoundary; + } + return prev; + } + sessionExpired = true; } - return; + sessionCreatedAtRef.current = Date.now(); + const now = getServerAnchoredDBTime(); + if (unreadBoundary && unreadBoundary < now) { + return unreadBoundary; + } + return now; + }); + if (sessionExpired) { + setShowFullHistory(false); + setHadMessagesAtSessionStart(false); } - - const now = getServerAnchoredDBTime(); - sessionCreatedAtRef.current = Date.now(); - setSessionStartTime(unreadBoundary && unreadBoundary < now ? unreadBoundary : now); - setShowFullHistory(false); - setHadMessagesAtSessionStart(false); }, []); const resetSession = useCallback(() => { From f68bab51194043bc0f939ccf3d7c5a244be8033a Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 14 Sep 2026 04:38:02 +0200 Subject: [PATCH 019/156] Reapply "Fix session race" This reverts commit eca32a2b9c9cb8cdf64f2779db9db47480d6982a. --- src/pages/inbox/ConciergeSessionContext.tsx | 45 +++++++++------------ 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/src/pages/inbox/ConciergeSessionContext.tsx b/src/pages/inbox/ConciergeSessionContext.tsx index 51e60a7be3a5..cfa995ab0ace 100644 --- a/src/pages/inbox/ConciergeSessionContext.tsx +++ b/src/pages/inbox/ConciergeSessionContext.tsx @@ -70,6 +70,7 @@ function ConciergeSessionProvider({children}: PropsWithChildren) { if (prevAccountID !== accountID) { setPrevAccountID(accountID); if (sessionStartTime !== null) { + sessionCreatedAtRef.current = null; setSessionStartTime(null); setShowFullHistory(false); setHadMessagesAtSessionStart(false); @@ -77,35 +78,25 @@ function ConciergeSessionProvider({children}: PropsWithChildren) { } const startSession = useCallback((unreadBoundary?: string | null) => { - let sessionExpired = false; - setSessionStartTime((prev) => { - if (prev && sessionCreatedAtRef.current) { - const elapsed = Date.now() - sessionCreatedAtRef.current; - if (elapsed < CONST.CONCIERGE_SESSION_EXPIRATION_MS) { - // Within an active session, keep the existing boundary unless a better - // (earlier) unread boundary resolves after the session was created. On a - // cold open the session can lock to `now` before the unread anchor resolves; - // when it arrives we pull sessionStartTime back so the notification message - // isn't hidden behind "Show full history". The session age (sessionCreatedAtRef) - // is unchanged — only the display boundary is refined. - if (unreadBoundary && unreadBoundary < prev) { - return unreadBoundary; - } - return prev; - } - sessionExpired = true; + const sessionCreatedAt = sessionCreatedAtRef.current; + if (sessionCreatedAt !== null && Date.now() - sessionCreatedAt < CONST.CONCIERGE_SESSION_EXPIRATION_MS) { + // Within an active session, keep the existing boundary unless a better + // (earlier) unread boundary resolves after the session was created. On a + // cold open the session can lock to `now` before the unread anchor resolves; + // when it arrives we pull sessionStartTime back so the notification message + // isn't hidden behind "Show full history". The session age (sessionCreatedAtRef) + // is unchanged — only the display boundary is refined. + if (unreadBoundary) { + setSessionStartTime((prev) => (prev && unreadBoundary < prev ? unreadBoundary : prev)); } - sessionCreatedAtRef.current = Date.now(); - const now = getServerAnchoredDBTime(); - if (unreadBoundary && unreadBoundary < now) { - return unreadBoundary; - } - return now; - }); - if (sessionExpired) { - setShowFullHistory(false); - setHadMessagesAtSessionStart(false); + return; } + + const now = getServerAnchoredDBTime(); + sessionCreatedAtRef.current = Date.now(); + setSessionStartTime(unreadBoundary && unreadBoundary < now ? unreadBoundary : now); + setShowFullHistory(false); + setHadMessagesAtSessionStart(false); }, []); const resetSession = useCallback(() => { From c5c99757830682558ced64d8c25aff7ed8c2f541 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 14 Sep 2026 05:05:10 +0200 Subject: [PATCH 020/156] Fix CI checks --- src/pages/inbox/ConciergeSessionContext.tsx | 42 ++++++++++--------- .../ReportActionCompose/ComposerInput.tsx | 5 ++- src/pages/inbox/report/ReportActionsList.tsx | 2 +- 3 files changed, 26 insertions(+), 23 deletions(-) diff --git a/src/pages/inbox/ConciergeSessionContext.tsx b/src/pages/inbox/ConciergeSessionContext.tsx index cfa995ab0ace..97f5fd0265f7 100644 --- a/src/pages/inbox/ConciergeSessionContext.tsx +++ b/src/pages/inbox/ConciergeSessionContext.tsx @@ -70,34 +70,36 @@ function ConciergeSessionProvider({children}: PropsWithChildren) { if (prevAccountID !== accountID) { setPrevAccountID(accountID); if (sessionStartTime !== null) { - sessionCreatedAtRef.current = null; setSessionStartTime(null); setShowFullHistory(false); setHadMessagesAtSessionStart(false); } } - const startSession = useCallback((unreadBoundary?: string | null) => { - const sessionCreatedAt = sessionCreatedAtRef.current; - if (sessionCreatedAt !== null && Date.now() - sessionCreatedAt < CONST.CONCIERGE_SESSION_EXPIRATION_MS) { - // Within an active session, keep the existing boundary unless a better - // (earlier) unread boundary resolves after the session was created. On a - // cold open the session can lock to `now` before the unread anchor resolves; - // when it arrives we pull sessionStartTime back so the notification message - // isn't hidden behind "Show full history". The session age (sessionCreatedAtRef) - // is unchanged — only the display boundary is refined. - if (unreadBoundary) { - setSessionStartTime((prev) => (prev && unreadBoundary < prev ? unreadBoundary : prev)); + const startSession = useCallback( + (unreadBoundary?: string | null) => { + const sessionCreatedAt = sessionCreatedAtRef.current; + if (sessionStartTime !== null && sessionCreatedAt !== null && Date.now() - sessionCreatedAt < CONST.CONCIERGE_SESSION_EXPIRATION_MS) { + // Within an active session, keep the existing boundary unless a better + // (earlier) unread boundary resolves after the session was created. On a + // cold open the session can lock to `now` before the unread anchor resolves; + // when it arrives we pull sessionStartTime back so the notification message + // isn't hidden behind "Show full history". The session age (sessionCreatedAtRef) + // is unchanged — only the display boundary is refined. + if (unreadBoundary) { + setSessionStartTime((prev) => (prev && unreadBoundary < prev ? unreadBoundary : prev)); + } + return; } - return; - } - const now = getServerAnchoredDBTime(); - sessionCreatedAtRef.current = Date.now(); - setSessionStartTime(unreadBoundary && unreadBoundary < now ? unreadBoundary : now); - setShowFullHistory(false); - setHadMessagesAtSessionStart(false); - }, []); + const now = getServerAnchoredDBTime(); + sessionCreatedAtRef.current = Date.now(); + setSessionStartTime(unreadBoundary && unreadBoundary < now ? unreadBoundary : now); + setShowFullHistory(false); + setHadMessagesAtSessionStart(false); + }, + [sessionStartTime], + ); const resetSession = useCallback(() => { sessionCreatedAtRef.current = Date.now(); diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx index d68000f9189e..820ae2d740d8 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx @@ -23,7 +23,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {MeasureInWindowOnSuccessCallback} from 'react-native'; -import React, {useRef} from 'react'; +import React, {useState} from 'react'; import {useComposerActions, useComposerEditState, useComposerMeta, useComposerSendState, useComposerState} from './ComposerContext'; import ComposerWithSuggestions from './ComposerWithSuggestions'; @@ -88,7 +88,8 @@ function ComposerInput() { const isReportArchived = useReportIsArchived(report?.reportID); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const isAskConciergeChat = !!reportID && reportID === conciergeReportID && isBetaEnabled(CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD); - const askConciergePlaceholderKey = useRef(ASK_CONCIERGE_PLACEHOLDER_KEYS[Math.floor(Math.random() * ASK_CONCIERGE_PLACEHOLDER_KEYS.length)]).current; + // Capture once so render stays pure and the placeholder doesn't change under the user as they type. + const [askConciergePlaceholderKey] = useState(() => ASK_CONCIERGE_PLACEHOLDER_KEYS[Math.floor(Math.random() * ASK_CONCIERGE_PLACEHOLDER_KEYS.length)]); const includesConcierge = chatIncludesConcierge({participants: report?.participants}); const isGroupPolicyReport = !!report?.policyID && report.policyID !== CONST.POLICY.ID_FAKE; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index d8009d784c1a..61ad053baf1e 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -60,12 +60,12 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {ListRenderItemInfo} from '@shopify/flash-list'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; -import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useRoute} from '@react-navigation/native'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import React, {useEffect, useRef, useState} from 'react'; +import {View} from 'react-native'; import ConciergeChatHistoryToggle from './ConciergeChatHistoryToggle'; import ConciergeWelcome from './ConciergeWelcome'; From c278aec511e662af8608e4947a5b938cbaa86341 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 14 Sep 2026 05:16:03 +0200 Subject: [PATCH 021/156] Drop CSV placeholder --- src/languages/de.ts | 1 - src/languages/el.ts | 1 - src/languages/en.ts | 1 - src/languages/es.ts | 1 - src/languages/fr.ts | 1 - src/languages/it.ts | 1 - src/languages/ja.ts | 1 - src/languages/nl.ts | 1 - src/languages/pl.ts | 1 - src/languages/pt-BR.ts | 1 - src/languages/zh-hans.ts | 1 - src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx | 1 - 12 files changed, 12 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index f84a261dd611..961d194cc6c8 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -516,7 +516,6 @@ const translations: TranslationDeepObject = { createExpense: 'Erstelle eine Ausgabe über 30 $ für ein Mittagessen', combineExpenses: 'Fasse alle meine offenen Ausgaben in einem Bericht zusammen', exportPdf: 'Exportiere meinen letzten Bericht als PDF', - exportCsv: 'Gib mir eine CSV meiner Ausgaben über 50 $', }, }, duplicateReport: 'Duplizierten Bericht', diff --git a/src/languages/el.ts b/src/languages/el.ts index 05ba6854af2e..0779d3e6981f 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -518,7 +518,6 @@ const translations: TranslationDeepObject = { createExpense: 'Δημιούργησε μια δαπάνη 30 $ για ένα γεύμα', combineExpenses: 'Συνδύασε όλες τις ανοιχτές δαπάνες μου σε μία αναφορά', exportPdf: 'Εξήγαγε την τελευταία μου αναφορά σε PDF', - exportCsv: 'Δώσε μου ένα CSV με τις δαπάνες μου άνω των 50 $', }, }, vacationDelegate: 'Εκπρόσωπος διακοπών', diff --git a/src/languages/en.ts b/src/languages/en.ts index 83b582035648..6a6456b71829 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -563,7 +563,6 @@ const translations = { createExpense: 'Create an expense for a $30 lunch', combineExpenses: 'Combine all my open expenses into one report', exportPdf: 'Export my last report to PDF', - exportCsv: 'Give me a CSV of my expenses over $50', }, }, vacationDelegate: 'Vacation delegate', diff --git a/src/languages/es.ts b/src/languages/es.ts index 98b63b1c0429..81e9b440f31b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -507,7 +507,6 @@ const translations: TranslationDeepObject = { createExpense: 'Crea un gasto de $30 por un almuerzo', combineExpenses: 'Combina todos mis gastos abiertos en un informe', exportPdf: 'Exporta mi último informe a PDF', - exportCsv: 'Dame un CSV de mis gastos de más de $50', }, }, vacationDelegate: 'Delegado de vacaciones', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index adbfd44e6967..aca2f74371ba 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -516,7 +516,6 @@ const translations: TranslationDeepObject = { createExpense: 'Crée une dépense de 30 $ pour un déjeuner', combineExpenses: 'Regroupe toutes mes dépenses ouvertes dans un seul rapport', exportPdf: 'Exporte mon dernier rapport en PDF', - exportCsv: 'Donne-moi un CSV de mes dépenses de plus de 50 $', }, }, duplicateReport: 'Note de frais en double', diff --git a/src/languages/it.ts b/src/languages/it.ts index 92b95d97da96..e8e7bda88601 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -515,7 +515,6 @@ const translations: TranslationDeepObject = { createExpense: 'Crea una spesa di 30 $ per un pranzo', combineExpenses: 'Unisci tutte le mie spese aperte in un unico report', exportPdf: 'Esporta il mio ultimo report in PDF', - exportCsv: 'Dammi un CSV delle mie spese superiori a 50 $', }, }, duplicateReport: 'Report duplicato', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 2d943679cf77..9f4dcf446355 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -514,7 +514,6 @@ const translations: TranslationDeepObject = { createExpense: 'ランチの30ドルの経費を作成して', combineExpenses: '未処理の経費をすべて1つのレポートにまとめて', exportPdf: '直近のレポートをPDFでエクスポートして', - exportCsv: '50ドル以上の経費のCSVをください', }, }, duplicateReport: 'レポートを複製', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index f78789291da9..ec078a7b305b 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -515,7 +515,6 @@ const translations: TranslationDeepObject = { createExpense: 'Maak een uitgave van $30 voor een lunch', combineExpenses: 'Voeg al mijn open uitgaven samen in één rapport', exportPdf: 'Exporteer mijn laatste rapport naar PDF', - exportCsv: 'Geef me een CSV van mijn uitgaven boven $50', }, }, duplicateReport: 'Dubbel rapport', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index cdebd0b41ea2..9632f9e9b933 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -518,7 +518,6 @@ const translations: TranslationDeepObject = { createExpense: 'Utwórz wydatek 30 $ na lunch', combineExpenses: 'Połącz wszystkie moje otwarte wydatki w jeden raport', exportPdf: 'Wyeksportuj mój ostatni raport do PDF', - exportCsv: 'Daj mi plik CSV z moimi wydatkami powyżej 50 $', }, }, duplicateReport: 'Zduplikowany raport', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index c7c9ef3e903d..29767739a153 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -514,7 +514,6 @@ const translations: TranslationDeepObject = { createExpense: 'Crie uma despesa de US$ 30 para um almoço', combineExpenses: 'Combine todas as minhas despesas abertas em um relatório', exportPdf: 'Exporte meu último relatório em PDF', - exportCsv: 'Me dê um CSV das minhas despesas acima de US$ 50', }, }, duplicateReport: 'Duplicar relatório', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index e0860f066261..2cfb51d7b059 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -510,7 +510,6 @@ const translations: TranslationDeepObject = { createExpense: '创建一笔 30 美元的午餐支出', combineExpenses: '把我所有未处理的支出合并到一个报告中', exportPdf: '将我最近的报告导出为 PDF', - exportCsv: '给我一份超过 50 美元的支出 CSV', }, }, duplicateReport: '重复报销单', diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx index 820ae2d740d8..df102b7c0bd3 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx @@ -46,7 +46,6 @@ const ASK_CONCIERGE_PLACEHOLDER_KEYS = [ 'common.concierge.composerPlaceholders.createExpense', 'common.concierge.composerPlaceholders.combineExpenses', 'common.concierge.composerPlaceholders.exportPdf', - 'common.concierge.composerPlaceholders.exportCsv', 'reportActionCompose.askConciergeForHelp', ] as const; From 134ddabc3506cbc51e997f7312ab55c921143247 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 14 Sep 2026 05:20:40 +0200 Subject: [PATCH 022/156] Revert "Drop CSV placeholder" This reverts commit c278aec511e662af8608e4947a5b938cbaa86341. --- src/languages/de.ts | 1 + src/languages/el.ts | 1 + src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx | 1 + 12 files changed, 12 insertions(+) diff --git a/src/languages/de.ts b/src/languages/de.ts index 961d194cc6c8..f84a261dd611 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -516,6 +516,7 @@ const translations: TranslationDeepObject = { createExpense: 'Erstelle eine Ausgabe über 30 $ für ein Mittagessen', combineExpenses: 'Fasse alle meine offenen Ausgaben in einem Bericht zusammen', exportPdf: 'Exportiere meinen letzten Bericht als PDF', + exportCsv: 'Gib mir eine CSV meiner Ausgaben über 50 $', }, }, duplicateReport: 'Duplizierten Bericht', diff --git a/src/languages/el.ts b/src/languages/el.ts index 0779d3e6981f..05ba6854af2e 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -518,6 +518,7 @@ const translations: TranslationDeepObject = { createExpense: 'Δημιούργησε μια δαπάνη 30 $ για ένα γεύμα', combineExpenses: 'Συνδύασε όλες τις ανοιχτές δαπάνες μου σε μία αναφορά', exportPdf: 'Εξήγαγε την τελευταία μου αναφορά σε PDF', + exportCsv: 'Δώσε μου ένα CSV με τις δαπάνες μου άνω των 50 $', }, }, vacationDelegate: 'Εκπρόσωπος διακοπών', diff --git a/src/languages/en.ts b/src/languages/en.ts index 6a6456b71829..83b582035648 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -563,6 +563,7 @@ const translations = { createExpense: 'Create an expense for a $30 lunch', combineExpenses: 'Combine all my open expenses into one report', exportPdf: 'Export my last report to PDF', + exportCsv: 'Give me a CSV of my expenses over $50', }, }, vacationDelegate: 'Vacation delegate', diff --git a/src/languages/es.ts b/src/languages/es.ts index 81e9b440f31b..98b63b1c0429 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -507,6 +507,7 @@ const translations: TranslationDeepObject = { createExpense: 'Crea un gasto de $30 por un almuerzo', combineExpenses: 'Combina todos mis gastos abiertos en un informe', exportPdf: 'Exporta mi último informe a PDF', + exportCsv: 'Dame un CSV de mis gastos de más de $50', }, }, vacationDelegate: 'Delegado de vacaciones', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index aca2f74371ba..adbfd44e6967 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -516,6 +516,7 @@ const translations: TranslationDeepObject = { createExpense: 'Crée une dépense de 30 $ pour un déjeuner', combineExpenses: 'Regroupe toutes mes dépenses ouvertes dans un seul rapport', exportPdf: 'Exporte mon dernier rapport en PDF', + exportCsv: 'Donne-moi un CSV de mes dépenses de plus de 50 $', }, }, duplicateReport: 'Note de frais en double', diff --git a/src/languages/it.ts b/src/languages/it.ts index e8e7bda88601..92b95d97da96 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -515,6 +515,7 @@ const translations: TranslationDeepObject = { createExpense: 'Crea una spesa di 30 $ per un pranzo', combineExpenses: 'Unisci tutte le mie spese aperte in un unico report', exportPdf: 'Esporta il mio ultimo report in PDF', + exportCsv: 'Dammi un CSV delle mie spese superiori a 50 $', }, }, duplicateReport: 'Report duplicato', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 9f4dcf446355..2d943679cf77 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -514,6 +514,7 @@ const translations: TranslationDeepObject = { createExpense: 'ランチの30ドルの経費を作成して', combineExpenses: '未処理の経費をすべて1つのレポートにまとめて', exportPdf: '直近のレポートをPDFでエクスポートして', + exportCsv: '50ドル以上の経費のCSVをください', }, }, duplicateReport: 'レポートを複製', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index ec078a7b305b..f78789291da9 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -515,6 +515,7 @@ const translations: TranslationDeepObject = { createExpense: 'Maak een uitgave van $30 voor een lunch', combineExpenses: 'Voeg al mijn open uitgaven samen in één rapport', exportPdf: 'Exporteer mijn laatste rapport naar PDF', + exportCsv: 'Geef me een CSV van mijn uitgaven boven $50', }, }, duplicateReport: 'Dubbel rapport', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 9632f9e9b933..cdebd0b41ea2 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -518,6 +518,7 @@ const translations: TranslationDeepObject = { createExpense: 'Utwórz wydatek 30 $ na lunch', combineExpenses: 'Połącz wszystkie moje otwarte wydatki w jeden raport', exportPdf: 'Wyeksportuj mój ostatni raport do PDF', + exportCsv: 'Daj mi plik CSV z moimi wydatkami powyżej 50 $', }, }, duplicateReport: 'Zduplikowany raport', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 29767739a153..c7c9ef3e903d 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -514,6 +514,7 @@ const translations: TranslationDeepObject = { createExpense: 'Crie uma despesa de US$ 30 para um almoço', combineExpenses: 'Combine todas as minhas despesas abertas em um relatório', exportPdf: 'Exporte meu último relatório em PDF', + exportCsv: 'Me dê um CSV das minhas despesas acima de US$ 50', }, }, duplicateReport: 'Duplicar relatório', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 2cfb51d7b059..e0860f066261 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -510,6 +510,7 @@ const translations: TranslationDeepObject = { createExpense: '创建一笔 30 美元的午餐支出', combineExpenses: '把我所有未处理的支出合并到一个报告中', exportPdf: '将我最近的报告导出为 PDF', + exportCsv: '给我一份超过 50 美元的支出 CSV', }, }, duplicateReport: '重复报销单', diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx index df102b7c0bd3..820ae2d740d8 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx @@ -46,6 +46,7 @@ const ASK_CONCIERGE_PLACEHOLDER_KEYS = [ 'common.concierge.composerPlaceholders.createExpense', 'common.concierge.composerPlaceholders.combineExpenses', 'common.concierge.composerPlaceholders.exportPdf', + 'common.concierge.composerPlaceholders.exportCsv', 'reportActionCompose.askConciergeForHelp', ] as const; From 0f7309a666e9b9a171b293e7162a74fad2ef0e3b Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 14 Sep 2026 05:24:28 +0200 Subject: [PATCH 023/156] Fix history toggle --- .../useConciergeSidePanelReportActions.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/hooks/useConciergeSidePanelReportActions.ts b/src/hooks/useConciergeSidePanelReportActions.ts index c272c899fa45..7eabde7a191a 100644 --- a/src/hooks/useConciergeSidePanelReportActions.ts +++ b/src/hooks/useConciergeSidePanelReportActions.ts @@ -91,14 +91,6 @@ function useConciergeSidePanelReportActions({ return hasUserMessageInLoadedSet || hasOlderActions; }, [isConciergeHiddenHistory, visibleReportActions, currentUserAccountID, sessionStartTime, hasOlderActions]); - const hasPreviousMessages = useMemo(() => { - if (!isConciergeHiddenHistory || !hadUserMessageAtSessionStart || !sessionStartTime) { - return false; - } - const hasPreSessionActionInLoadedSet = visibleReportActions.some((action) => !isCreatedAction(action) && action.created < sessionStartTime); - return hasPreSessionActionInLoadedSet || hasOlderActions; - }, [isConciergeHiddenHistory, visibleReportActions, sessionStartTime, hadUserMessageAtSessionStart, hasOlderActions]); - // Main DM only: check if there are any messages (from any actor) after sessionStartTime. // When true, we have unread content to display and should not enter welcome mode. const hasMessagesInSession = useMemo(() => { @@ -175,14 +167,14 @@ function useConciergeSidePanelReportActions({ if (!sessionStartTime) { return actions.filter(isCreatedAction); } - if (!hadUserMessageAtSessionStart) { + if (!isConciergeMainDM && !hadUserMessageAtSessionStart) { return actions; } const filtered = actions.filter(isCurrentSessionAction); - if (filtered.length === 0) { + if (filtered.length === 0 && !isConciergeMainDM) { // Side panel: nothing matched the current session yet (e.g. just after reopen, before the new // message propagates). Show the greeting instead of `actions` to avoid flashing stale history. - if (!isConciergeMainDM && conciergeGreetingAction) { + if (conciergeGreetingAction) { const createdAction = actions.find(isCreatedAction); return createdAction ? [conciergeGreetingAction, createdAction] : [conciergeGreetingAction]; } @@ -209,6 +201,14 @@ function useConciergeSidePanelReportActions({ const filteredVisibleActions = useMemo(() => filterActions(visibleReportActions), [filterActions, visibleReportActions]); const filteredReportActions = useMemo(() => filterActions(reportActions), [filterActions, reportActions]); + // Asks the session filter what it would hide, so the control is only offered when pressing it changes the list. + const hasPreviousMessages = useMemo(() => { + if (!isConciergeHiddenHistory || !sessionStartTime) { + return false; + } + return visibleReportActions.some((action) => !isCurrentSessionAction(action)) || hasOlderActions; + }, [isConciergeHiddenHistory, sessionStartTime, visibleReportActions, isCurrentSessionAction, hasOlderActions]); + const handleShowPreviousMessages = useCallback(() => { setShowFullHistory(true); loadOlderChats(true); From 75caf8dd2ad39b706d4bdd93f6250e953a974409 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 14 Sep 2026 05:25:00 +0200 Subject: [PATCH 024/156] Drop comment --- src/hooks/useConciergeSidePanelReportActions.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hooks/useConciergeSidePanelReportActions.ts b/src/hooks/useConciergeSidePanelReportActions.ts index 7eabde7a191a..cf5b87d486e8 100644 --- a/src/hooks/useConciergeSidePanelReportActions.ts +++ b/src/hooks/useConciergeSidePanelReportActions.ts @@ -201,7 +201,6 @@ function useConciergeSidePanelReportActions({ const filteredVisibleActions = useMemo(() => filterActions(visibleReportActions), [filterActions, visibleReportActions]); const filteredReportActions = useMemo(() => filterActions(reportActions), [filterActions, reportActions]); - // Asks the session filter what it would hide, so the control is only offered when pressing it changes the list. const hasPreviousMessages = useMemo(() => { if (!isConciergeHiddenHistory || !sessionStartTime) { return false; From 3e8dfe7bd07cabf9a59a1b44822fe2118aa9f948 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 14 Sep 2026 05:29:34 +0200 Subject: [PATCH 025/156] Welcome instead of skeleton --- src/hooks/useConciergeAskState.ts | 8 +++-- .../report/ReportActionsSkeletonGuard.tsx | 30 ++++++++++++------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/hooks/useConciergeAskState.ts b/src/hooks/useConciergeAskState.ts index 5e5c0fc7f006..bf15b4837e3d 100644 --- a/src/hooks/useConciergeAskState.ts +++ b/src/hooks/useConciergeAskState.ts @@ -48,13 +48,15 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { selector: hasSessionActivitySelector, }); + const isAskConciergeChat = !!reportID && reportID === conciergeReportID && !isInSidePanel && isBetaEnabled(CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD); + // Waiting for the session boundary keeps an existing conversation from flashing the welcome screen on open. - const isAskConciergeChat = !!reportID && reportID === conciergeReportID && !isInSidePanel && !!sessionStartTime && isBetaEnabled(CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD); + const hasSessionBoundary = isAskConciergeChat && !!sessionStartTime; return { isAskConciergeChat, - shouldShowWelcome: isAskConciergeChat && !showFullHistory && !hasSessionActivity, - shouldLabelComposerAsNewQuestion: isAskConciergeChat && showFullHistory && !hasSessionActivity, + shouldShowWelcome: hasSessionBoundary && !showFullHistory && !hasSessionActivity, + shouldLabelComposerAsNewQuestion: hasSessionBoundary && showFullHistory && !hasSessionActivity, }; } diff --git a/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx b/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx index b65a2e66dcaa..41b09fc4916d 100644 --- a/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx +++ b/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx @@ -1,16 +1,22 @@ import useBackfillWhenNoVisibleActions from '@hooks/useBackfillWhenNoVisibleActions'; +import useConciergeAskState from '@hooks/useConciergeAskState'; import useCopySelectionHelper from '@hooks/useCopySelectionHelper'; import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; import usePendingConciergeResponse from '@hooks/usePendingConciergeResponse'; import useReportActionsListModel from '@hooks/useReportActionsListModel'; import useStartConciergeSession from '@hooks/useStartConciergeSession'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; import CONST from '@src/CONST'; import type {ReactNode} from 'react'; import React from 'react'; +import {View} from 'react-native'; +import ConciergeWelcome from './ConciergeWelcome'; import {computeReportActionsSkeletonState, ReportActionsListActionsContext, ReportActionsListStateContext} from './ReportActionsListContext'; import ReportActionsLoadingSkeleton from './ReportActionsLoadingSkeleton'; @@ -31,7 +37,10 @@ type ReportActionsSkeletonGuardProps = { * */ function ReportActionsSkeletonGuard({reportID, children}: ReportActionsSkeletonGuardProps) { + const styles = useThemeStyles(); const isReportLoadPending = useIsReportLoadPending(reportID); + const {isAskConciergeChat} = useConciergeAskState(reportID); + const {showFullHistory} = useConciergeSessionState(); const {readinessSignals, state, actions} = useReportActionsListModel(reportID, isReportLoadPending); const {shouldShowLoadingSkeleton, shouldShowDerivedTimingSkeleton} = computeReportActionsSkeletonState(readinessSignals); @@ -74,21 +83,20 @@ function ReportActionsSkeletonGuard({reportID, children}: ReportActionsSkeletonG loadOlderChats: actions.loadOlderChats, }); - if (shouldShowLoadingSkeleton) { - return ( - - ); - } + if (shouldShowLoadingSkeleton || shouldShowDerivedTimingSkeleton) { + if (isAskConciergeChat && !showFullHistory) { + return ( + + + + ); + } - if (shouldShowDerivedTimingSkeleton) { return ( ); } From dbb3eee73097ddaa377f959dd3fdd5df13ddb9b9 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 14 Sep 2026 05:34:13 +0200 Subject: [PATCH 026/156] Keep onboarding passthrough --- src/hooks/useConciergeSidePanelReportActions.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks/useConciergeSidePanelReportActions.ts b/src/hooks/useConciergeSidePanelReportActions.ts index cf5b87d486e8..f452b1309e06 100644 --- a/src/hooks/useConciergeSidePanelReportActions.ts +++ b/src/hooks/useConciergeSidePanelReportActions.ts @@ -167,7 +167,7 @@ function useConciergeSidePanelReportActions({ if (!sessionStartTime) { return actions.filter(isCreatedAction); } - if (!isConciergeMainDM && !hadUserMessageAtSessionStart) { + if (!hadUserMessageAtSessionStart) { return actions; } const filtered = actions.filter(isCurrentSessionAction); @@ -202,11 +202,11 @@ function useConciergeSidePanelReportActions({ const filteredReportActions = useMemo(() => filterActions(reportActions), [filterActions, reportActions]); const hasPreviousMessages = useMemo(() => { - if (!isConciergeHiddenHistory || !sessionStartTime) { + if (!isConciergeHiddenHistory || !hadUserMessageAtSessionStart || !sessionStartTime) { return false; } return visibleReportActions.some((action) => !isCurrentSessionAction(action)) || hasOlderActions; - }, [isConciergeHiddenHistory, sessionStartTime, visibleReportActions, isCurrentSessionAction, hasOlderActions]); + }, [isConciergeHiddenHistory, hadUserMessageAtSessionStart, sessionStartTime, visibleReportActions, isCurrentSessionAction, hasOlderActions]); const handleShowPreviousMessages = useCallback(() => { setShowFullHistory(true); From 7c9c6bdc4aa2e79957acb5bf4fae1a70ea5a2b00 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 14 Sep 2026 05:34:32 +0200 Subject: [PATCH 027/156] Remove unused asset --- assets/images/chart-pie.svg | 1 - 1 file changed, 1 deletion(-) delete mode 100644 assets/images/chart-pie.svg diff --git a/assets/images/chart-pie.svg b/assets/images/chart-pie.svg deleted file mode 100644 index 97da1ef66cef..000000000000 --- a/assets/images/chart-pie.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file From df8069eb606d2f88b0edb23de55e2aac674ac5e0 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 14 Sep 2026 05:35:46 +0200 Subject: [PATCH 028/156] Rename welcome component --- ...nciergeWelcome.tsx => AskConciergeEmptyState.tsx} | 12 ++++++------ src/pages/inbox/report/ReportActionsList.tsx | 4 ++-- .../inbox/report/ReportActionsSkeletonGuard.tsx | 4 ++-- src/styles/index.ts | 6 +++--- 4 files changed, 13 insertions(+), 13 deletions(-) rename src/pages/inbox/report/{ConciergeWelcome.tsx => AskConciergeEmptyState.tsx} (67%) diff --git a/src/pages/inbox/report/ConciergeWelcome.tsx b/src/pages/inbox/report/AskConciergeEmptyState.tsx similarity index 67% rename from src/pages/inbox/report/ConciergeWelcome.tsx rename to src/pages/inbox/report/AskConciergeEmptyState.tsx index 3bc242589bf1..664c061ab260 100644 --- a/src/pages/inbox/report/ConciergeWelcome.tsx +++ b/src/pages/inbox/report/AskConciergeEmptyState.tsx @@ -14,25 +14,25 @@ const ILLUSTRATION_SIZE = 68; /** * Empty state shown in the main Concierge DM before the user asks anything. */ -function ConciergeWelcome() { +function AskConciergeEmptyState() { const {translate} = useLocalize(); const styles = useThemeStyles(); const illustrations = useMemoizedLazyIllustrations(ILLUSTRATION_NAMES); return ( - {translate('reportActionsView.askMeAnything')} - {translate('common.concierge.welcomeDescription')} + {translate('reportActionsView.askMeAnything')} + {translate('common.concierge.welcomeDescription')} ); } -export default ConciergeWelcome; +export default AskConciergeEmptyState; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 61ad053baf1e..948da78e59ba 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -67,8 +67,8 @@ import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; +import AskConciergeEmptyState from './AskConciergeEmptyState'; import ConciergeChatHistoryToggle from './ConciergeChatHistoryToggle'; -import ConciergeWelcome from './ConciergeWelcome'; import FloatingMessageCounter from './FloatingMessageCounter'; import ReportActionIndexContext from './ReportActionIndexContext'; import {useReportActionsListActions, useReportActionsListState} from './ReportActionsListContext'; @@ -464,7 +464,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct if (shouldShowConciergeWelcome) { return ( - + - + ); } diff --git a/src/styles/index.ts b/src/styles/index.ts index dd834365b091..3d5147cf09f8 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -2384,12 +2384,12 @@ const staticStyles = (theme: ThemeColors) => alignSelf: 'center', }, - conciergeWelcomeContainer: { + askConciergeEmptyStateContainer: { alignItems: 'center', gap: 12, }, - conciergeWelcomeTitle: { + askConciergeEmptyStateTitle: { ...FontUtils.fontFamily.platform.EXP_NEW_KANSAS_MEDIUM, fontSize: variables.fontSizeXLarge, lineHeight: variables.lineHeightSizeH1, @@ -2397,7 +2397,7 @@ const staticStyles = (theme: ThemeColors) => color: theme.text, }, - conciergeWelcomeDescription: { + askConciergeEmptyStateDescription: { ...FontUtils.fontFamily.platform.EXP_NEUE, fontSize: variables.fontSizeNormal, lineHeight: variables.fontSizeNormalHeight, From 9e7242b86b727e3eeebbb0861a4ddb1ca0aabe53 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 14 Sep 2026 05:37:54 +0200 Subject: [PATCH 029/156] Add regression test --- tests/ui/ReportActionsListTest.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index c68e081a6307..766ed71ed6c2 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -951,6 +951,23 @@ describe('ReportActionsList (body)', () => { expect(passedActions?.some((a) => a.reportActionID === 'onboarding-msg')).toBe(true); }); + it('should hide pre-session messages when nothing matches the session and there is no created action', () => { + setupMainDMConciergeMocks(); + + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: oldReportActions.filter((action) => action.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED), + hasOlderActions: true, + }); + + renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); + + expect(mockInvertedFlashList).toHaveBeenCalled(); + const passedActions = getCapturedVisibleActions(); + expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(false); + expect(passedActions?.some((a) => a.reportActionID === 'old-concierge-msg')).toBe(false); + }); + it('should call startSession on mount for main DM concierge', () => { const mockStartSession = jest.fn(); mockUseConciergeSessionActions.mockReturnValue({startSession: mockStartSession, resetSession: jest.fn(), setShowFullHistory: jest.fn(), setHadMessagesAtSessionStart: jest.fn()}); From 7b057b0293518d167459694bf5c3aea2f57a3aab Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 14 Sep 2026 05:40:37 +0200 Subject: [PATCH 030/156] Drop comments --- src/hooks/useConciergeAskState.ts | 8 -------- src/pages/inbox/report/AskConciergeEmptyState.tsx | 3 --- src/pages/inbox/report/ConciergeChatHistoryToggle.tsx | 4 ---- 3 files changed, 15 deletions(-) diff --git a/src/hooks/useConciergeAskState.ts b/src/hooks/useConciergeAskState.ts index bf15b4837e3d..00f161d45086 100644 --- a/src/hooks/useConciergeAskState.ts +++ b/src/hooks/useConciergeAskState.ts @@ -24,11 +24,6 @@ type ConciergeAskState = { shouldLabelComposerAsNewQuestion: boolean; }; -/** - * Drives the Ask Concierge screen in the main Concierge DM. The empty state lives in the report actions - * list and the composer label lives in the report footer, and those are siblings, so both derive their - * state here rather than one passing it to the other. - */ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { const {isBetaEnabled} = usePermissions(); const isInSidePanel = useIsInSidePanel(); @@ -36,8 +31,6 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - // Derive the boolean inside the Onyx selector so the screen re-renders only when session activity - // flips, not on every report-action change in the Concierge chat. const hasSessionActivitySelector = (actions: OnyxEntry) => { if (!actions || !sessionStartTime) { return false; @@ -50,7 +43,6 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { const isAskConciergeChat = !!reportID && reportID === conciergeReportID && !isInSidePanel && isBetaEnabled(CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD); - // Waiting for the session boundary keeps an existing conversation from flashing the welcome screen on open. const hasSessionBoundary = isAskConciergeChat && !!sessionStartTime; return { diff --git a/src/pages/inbox/report/AskConciergeEmptyState.tsx b/src/pages/inbox/report/AskConciergeEmptyState.tsx index 664c061ab260..569f1bcc1780 100644 --- a/src/pages/inbox/report/AskConciergeEmptyState.tsx +++ b/src/pages/inbox/report/AskConciergeEmptyState.tsx @@ -11,9 +11,6 @@ import {View} from 'react-native'; const ILLUSTRATION_NAMES = ['ConciergeBot'] as const; const ILLUSTRATION_SIZE = 68; -/** - * Empty state shown in the main Concierge DM before the user asks anything. - */ function AskConciergeEmptyState() { const {translate} = useLocalize(); const styles = useThemeStyles(); diff --git a/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx b/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx index 09f852bb18e4..2ab3d0c6bad3 100644 --- a/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx +++ b/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx @@ -28,10 +28,6 @@ type ConciergeChatHistoryToggleProps = { containerStyles?: StyleProp; }; -/** - * Expands and collapses the earlier Concierge conversation in the main Concierge DM. It sits directly - * above the composer, either in the welcome column or in the inverted list's header. - */ function ConciergeChatHistoryToggle({reportID, hasPreviousMessages, onShowPreviousMessages, containerStyles}: ConciergeChatHistoryToggleProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); From 16d4486977a3e1d7a611fcc6ce7ea073c9e7de87 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 14 Sep 2026 05:41:54 +0200 Subject: [PATCH 031/156] Drop comment --- src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx index 820ae2d740d8..b31436aee442 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx @@ -88,7 +88,6 @@ function ComposerInput() { const isReportArchived = useReportIsArchived(report?.reportID); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const isAskConciergeChat = !!reportID && reportID === conciergeReportID && isBetaEnabled(CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD); - // Capture once so render stays pure and the placeholder doesn't change under the user as they type. const [askConciergePlaceholderKey] = useState(() => ASK_CONCIERGE_PLACEHOLDER_KEYS[Math.floor(Math.random() * ASK_CONCIERGE_PLACEHOLDER_KEYS.length)]); const includesConcierge = chatIncludesConcierge({participants: report?.participants}); From 5faf565f4b558ae25ddc2fe6b1d216297887836e Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 14 Sep 2026 05:43:45 +0200 Subject: [PATCH 032/156] Fix review findings --- .../inbox/report/ReportActionCompose/ComposerInput.tsx | 6 ++---- src/pages/inbox/report/ReportActionsSkeletonGuard.tsx | 7 ++----- src/pages/inbox/report/ShowPreviousMessagesButton.tsx | 4 +++- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx index b31436aee442..445093d4fc5f 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx @@ -1,8 +1,8 @@ import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import useConciergeAskState from '@hooks/useConciergeAskState'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import useReportIsArchived from '@hooks/useReportIsArchived'; import {setIsComposerFullSize} from '@libs/actions/Report'; @@ -63,7 +63,6 @@ function ComposerInput() { const {setIsFullComposerAvailable, onBlur, onFocus, setComposerRef} = useComposerActions(); const {containerRef, suggestionsRef, isNextModalWillOpenRef} = useComposerMeta(); const {isEditingInComposer, didResetComposerHeightWhileEditing} = useComposerEditState(); - const {isBetaEnabled} = usePermissions(); const isSubmittingEdit = isEditingInComposer || didResetComposerHeightWhileEditing; const {submitDraftAndClearComposer, validateAndSubmitDraft} = useComposerSubmit(reportID); @@ -86,8 +85,7 @@ function ComposerInput() { const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const isReportArchived = useReportIsArchived(report?.reportID); - const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - const isAskConciergeChat = !!reportID && reportID === conciergeReportID && isBetaEnabled(CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD); + const {isAskConciergeChat} = useConciergeAskState(reportID); const [askConciergePlaceholderKey] = useState(() => ASK_CONCIERGE_PLACEHOLDER_KEYS[Math.floor(Math.random() * ASK_CONCIERGE_PLACEHOLDER_KEYS.length)]); const includesConcierge = chatIncludesConcierge({participants: report?.participants}); diff --git a/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx b/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx index 6c35f4596c55..816e33091ee1 100644 --- a/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx +++ b/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx @@ -7,8 +7,6 @@ import useReportActionsListModel from '@hooks/useReportActionsListModel'; import useStartConciergeSession from '@hooks/useStartConciergeSession'; import useThemeStyles from '@hooks/useThemeStyles'; -import {useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; - import CONST from '@src/CONST'; import type {ReactNode} from 'react'; @@ -39,8 +37,7 @@ type ReportActionsSkeletonGuardProps = { function ReportActionsSkeletonGuard({reportID, children}: ReportActionsSkeletonGuardProps) { const styles = useThemeStyles(); const isReportLoadPending = useIsReportLoadPending(reportID); - const {isAskConciergeChat} = useConciergeAskState(reportID); - const {showFullHistory} = useConciergeSessionState(); + const {shouldShowWelcome} = useConciergeAskState(reportID); const {readinessSignals, state, actions} = useReportActionsListModel(reportID, isReportLoadPending); const {shouldShowLoadingSkeleton, shouldShowDerivedTimingSkeleton} = computeReportActionsSkeletonState(readinessSignals); @@ -84,7 +81,7 @@ function ReportActionsSkeletonGuard({reportID, children}: ReportActionsSkeletonG }); if (shouldShowLoadingSkeleton || shouldShowDerivedTimingSkeleton) { - if (isAskConciergeChat && !showFullHistory) { + if (shouldShowWelcome) { return ( diff --git a/src/pages/inbox/report/ShowPreviousMessagesButton.tsx b/src/pages/inbox/report/ShowPreviousMessagesButton.tsx index 367ad36d566a..3b2d815ebed6 100644 --- a/src/pages/inbox/report/ShowPreviousMessagesButton.tsx +++ b/src/pages/inbox/report/ShowPreviousMessagesButton.tsx @@ -1,5 +1,6 @@ import Button from '@components/Button'; +import useIsInSidePanel from '@hooks/useIsInSidePanel'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -32,6 +33,7 @@ function ShowPreviousMessagesButton({reportID, actionType, hasPreviousMessages, const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['UpArrow']); const {isBetaEnabled} = usePermissions(); + const isInSidePanel = useIsInSidePanel(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const isConciergeChat = reportID === conciergeReportID; @@ -39,7 +41,7 @@ function ShowPreviousMessagesButton({reportID, actionType, hasPreviousMessages, return null; } // ConciergeChatHistoryToggle takes over in the Ask Concierge design, where the control also collapses the history again. - if (isBetaEnabled(CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD)) { + if (!isInSidePanel && isBetaEnabled(CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD)) { return null; } if (!onPress) { From c6aeb51fc870e376595ee3c31bad9caae8d70452 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 14 Sep 2026 05:44:38 +0200 Subject: [PATCH 033/156] Test side panel button --- tests/unit/ShowPreviousMessagesButtonTest.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/unit/ShowPreviousMessagesButtonTest.tsx b/tests/unit/ShowPreviousMessagesButtonTest.tsx index 8203eee4caec..84f1a787acaf 100644 --- a/tests/unit/ShowPreviousMessagesButtonTest.tsx +++ b/tests/unit/ShowPreviousMessagesButtonTest.tsx @@ -97,14 +97,25 @@ describe('ShowPreviousMessagesButton', () => { expect(screen.queryByRole('button')).toBeNull(); }); - it('renders nothing when the Ask Concierge beta is enabled', async () => { + it('renders nothing in the main DM when the Ask Concierge beta is enabled', async () => { await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD]); await waitForBatchedUpdates(); - renderButton(); + renderButton({}, {isInSidePanel: false}); expect(screen.queryByRole('button')).toBeNull(); await Onyx.set(ONYXKEYS.BETAS, []); await waitForBatchedUpdates(); }); + + it('renders the button in the side panel when the Ask Concierge beta is enabled', async () => { + await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD]); + await waitForBatchedUpdates(); + + renderButton(); + expect(screen.getByRole('button')).toBeTruthy(); + + await Onyx.set(ONYXKEYS.BETAS, []); + await waitForBatchedUpdates(); + }); }); From bc1786192a51459cd8ed376779261f28dc80cc96 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 14 Sep 2026 05:47:35 +0200 Subject: [PATCH 034/156] Test toggle and empty state --- tests/unit/AskConciergeEmptyStateTest.tsx | 25 +++++ tests/unit/ConciergeChatHistoryToggleTest.tsx | 92 +++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 tests/unit/AskConciergeEmptyStateTest.tsx create mode 100644 tests/unit/ConciergeChatHistoryToggleTest.tsx diff --git a/tests/unit/AskConciergeEmptyStateTest.tsx b/tests/unit/AskConciergeEmptyStateTest.tsx new file mode 100644 index 000000000000..36ed160e69bb --- /dev/null +++ b/tests/unit/AskConciergeEmptyStateTest.tsx @@ -0,0 +1,25 @@ +import {render, screen} from '@testing-library/react-native'; + +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import AskConciergeEmptyState from '@pages/inbox/report/AskConciergeEmptyState'; + +import React from 'react'; + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyIllustrations: () => ({ConciergeBot: 'ConciergeBot'}), +})); + +describe('AskConciergeEmptyState', () => { + it('renders the Ask Concierge prompt', () => { + render( + + + , + ); + + expect(screen.getByTestId('AskConciergeEmptyState')).toBeTruthy(); + expect(screen.getByText('Ask me anything!')).toBeTruthy(); + expect(screen.getByText('Concierge can answer questions, update expenses, and more.')).toBeTruthy(); + }); +}); diff --git a/tests/unit/ConciergeChatHistoryToggleTest.tsx b/tests/unit/ConciergeChatHistoryToggleTest.tsx new file mode 100644 index 000000000000..c625e9bf77ca --- /dev/null +++ b/tests/unit/ConciergeChatHistoryToggleTest.tsx @@ -0,0 +1,92 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import {IsInSidePanelContext} from '@hooks/useIsInSidePanel'; + +import ConciergeChatHistoryToggle from '@pages/inbox/report/ConciergeChatHistoryToggle'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: () => ({UpArrow: 'UpArrow', DownArrow: 'DownArrow'}), +})); + +const mockSetShowFullHistory = jest.fn(); +let showFullHistory = false; + +jest.mock('@pages/inbox/ConciergeSessionContext', () => ({ + useConciergeSessionState: () => ({sessionStartTime: '2024-06-01 12:00:00.000', showFullHistory, hadMessagesAtSessionStart: false}), + useConciergeSessionActions: () => ({setShowFullHistory: mockSetShowFullHistory}), +})); + +const CONCIERGE_REPORT_ID = '1'; + +const renderToggle = (overrides: {hasPreviousMessages?: boolean} = {}) => { + const onShowPreviousMessages = jest.fn(); + render( + + + + + , + ); + return {onShowPreviousMessages}; +}; + +describe('ConciergeChatHistoryToggle', () => { + beforeAll(async () => { + await Onyx.set(ONYXKEYS.CONCIERGE_REPORT_ID, CONCIERGE_REPORT_ID); + await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD]); + await waitForBatchedUpdates(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + showFullHistory = false; + }); + + it('reveals the earlier conversation when history is hidden', () => { + const {onShowPreviousMessages} = renderToggle(); + + expect(screen.getByText('View chat history')).toBeTruthy(); + fireEvent.press(screen.getByRole('button')); + expect(onShowPreviousMessages).toHaveBeenCalledTimes(1); + }); + + it('collapses the earlier conversation when history is shown', () => { + showFullHistory = true; + const {onShowPreviousMessages} = renderToggle(); + + expect(screen.getByText('Hide chat history')).toBeTruthy(); + fireEvent.press(screen.getByRole('button')); + expect(mockSetShowFullHistory).toHaveBeenCalledWith(false); + expect(onShowPreviousMessages).not.toHaveBeenCalled(); + }); + + it('renders nothing when there is no earlier conversation to reveal', () => { + renderToggle({hasPreviousMessages: false}); + expect(screen.queryByRole('button')).toBeNull(); + }); + + it('renders nothing when the Ask Concierge beta is disabled', async () => { + await Onyx.set(ONYXKEYS.BETAS, []); + await waitForBatchedUpdates(); + + renderToggle(); + expect(screen.queryByRole('button')).toBeNull(); + + await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD]); + await waitForBatchedUpdates(); + }); +}); From fa104123991b5370568c52ba0dd0536e6b354f1d Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 14 Sep 2026 05:47:56 +0200 Subject: [PATCH 035/156] Fix mock scope --- tests/unit/ConciergeChatHistoryToggleTest.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/ConciergeChatHistoryToggleTest.tsx b/tests/unit/ConciergeChatHistoryToggleTest.tsx index c625e9bf77ca..0f0dbf7437df 100644 --- a/tests/unit/ConciergeChatHistoryToggleTest.tsx +++ b/tests/unit/ConciergeChatHistoryToggleTest.tsx @@ -19,10 +19,10 @@ jest.mock('@hooks/useLazyAsset', () => ({ })); const mockSetShowFullHistory = jest.fn(); -let showFullHistory = false; +let mockShowFullHistory = false; jest.mock('@pages/inbox/ConciergeSessionContext', () => ({ - useConciergeSessionState: () => ({sessionStartTime: '2024-06-01 12:00:00.000', showFullHistory, hadMessagesAtSessionStart: false}), + useConciergeSessionState: () => ({sessionStartTime: '2024-06-01 12:00:00.000', showFullHistory: mockShowFullHistory, hadMessagesAtSessionStart: false}), useConciergeSessionActions: () => ({setShowFullHistory: mockSetShowFullHistory}), })); @@ -53,7 +53,7 @@ describe('ConciergeChatHistoryToggle', () => { beforeEach(() => { jest.clearAllMocks(); - showFullHistory = false; + mockShowFullHistory = false; }); it('reveals the earlier conversation when history is hidden', () => { @@ -65,7 +65,7 @@ describe('ConciergeChatHistoryToggle', () => { }); it('collapses the earlier conversation when history is shown', () => { - showFullHistory = true; + mockShowFullHistory = true; const {onShowPreviousMessages} = renderToggle(); expect(screen.getByText('Hide chat history')).toBeTruthy(); From f693e6d1d94a6bf212f6d51204829f73bf4b4155 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 14 Sep 2026 06:00:27 +0200 Subject: [PATCH 036/156] Fix toggle label source --- .../inbox/report/ConciergeChatHistoryToggle.tsx | 14 ++++++++------ src/pages/inbox/report/ReportActionsList.tsx | 2 ++ tests/unit/ConciergeChatHistoryToggleTest.tsx | 10 ++++------ 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx b/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx index 2ab3d0c6bad3..303c7f1dc088 100644 --- a/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx +++ b/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx @@ -5,7 +5,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import {useConciergeSessionActions, useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; +import {useConciergeSessionActions} from '@pages/inbox/ConciergeSessionContext'; import CONST from '@src/CONST'; @@ -21,6 +21,9 @@ type ConciergeChatHistoryToggleProps = { /** Whether there are messages hidden before the session start */ hasPreviousMessages: boolean; + /** Whether the earlier conversation is currently shown */ + shouldShowFullHistory: boolean; + /** Callback to reveal the earlier conversation */ onShowPreviousMessages: () => void; @@ -28,12 +31,11 @@ type ConciergeChatHistoryToggleProps = { containerStyles?: StyleProp; }; -function ConciergeChatHistoryToggle({reportID, hasPreviousMessages, onShowPreviousMessages, containerStyles}: ConciergeChatHistoryToggleProps) { +function ConciergeChatHistoryToggle({reportID, hasPreviousMessages, shouldShowFullHistory, onShowPreviousMessages, containerStyles}: ConciergeChatHistoryToggleProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['UpArrow', 'DownArrow']); const {isAskConciergeChat} = useConciergeAskState(reportID); - const {showFullHistory} = useConciergeSessionState(); const {setShowFullHistory} = useConciergeSessionActions(); if (!isAskConciergeChat || !hasPreviousMessages) { @@ -45,10 +47,10 @@ function ConciergeChatHistoryToggle({reportID, hasPreviousMessages, onShowPrevio diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 948da78e59ba..5e99c6263264 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -420,6 +420,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct diff --git a/tests/unit/ConciergeChatHistoryToggleTest.tsx b/tests/unit/ConciergeChatHistoryToggleTest.tsx index 0f0dbf7437df..a1d149d017c9 100644 --- a/tests/unit/ConciergeChatHistoryToggleTest.tsx +++ b/tests/unit/ConciergeChatHistoryToggleTest.tsx @@ -19,16 +19,15 @@ jest.mock('@hooks/useLazyAsset', () => ({ })); const mockSetShowFullHistory = jest.fn(); -let mockShowFullHistory = false; jest.mock('@pages/inbox/ConciergeSessionContext', () => ({ - useConciergeSessionState: () => ({sessionStartTime: '2024-06-01 12:00:00.000', showFullHistory: mockShowFullHistory, hadMessagesAtSessionStart: false}), + useConciergeSessionState: () => ({sessionStartTime: '2024-06-01 12:00:00.000', showFullHistory: false, hadMessagesAtSessionStart: false}), useConciergeSessionActions: () => ({setShowFullHistory: mockSetShowFullHistory}), })); const CONCIERGE_REPORT_ID = '1'; -const renderToggle = (overrides: {hasPreviousMessages?: boolean} = {}) => { +const renderToggle = (overrides: {hasPreviousMessages?: boolean; shouldShowFullHistory?: boolean} = {}) => { const onShowPreviousMessages = jest.fn(); render( @@ -36,6 +35,7 @@ const renderToggle = (overrides: {hasPreviousMessages?: boolean} = {}) => { @@ -53,7 +53,6 @@ describe('ConciergeChatHistoryToggle', () => { beforeEach(() => { jest.clearAllMocks(); - mockShowFullHistory = false; }); it('reveals the earlier conversation when history is hidden', () => { @@ -65,8 +64,7 @@ describe('ConciergeChatHistoryToggle', () => { }); it('collapses the earlier conversation when history is shown', () => { - mockShowFullHistory = true; - const {onShowPreviousMessages} = renderToggle(); + const {onShowPreviousMessages} = renderToggle({shouldShowFullHistory: true}); expect(screen.getByText('Hide chat history')).toBeTruthy(); fireEvent.press(screen.getByRole('button')); From b84021c5e22ff489f202c4fc615f831e21b890c0 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 14 Sep 2026 06:02:07 +0200 Subject: [PATCH 037/156] Hide locked toggle --- src/pages/inbox/report/ReportActionsList.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 5e99c6263264..5169504f3b5d 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -51,6 +51,7 @@ import type {ReportsSplitNavigatorParamList} from '@navigation/types'; import {useActionListContext, useActionListRef} from '@pages/inbox/ActionListContext'; import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; +import {useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -188,6 +189,8 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const {clearDraft, revealDraftFromReportAction} = useConciergeDraftActions(); const showHiddenHistory = isConciergeHiddenHistory && !showFullHistory; + const {showFullHistory: isHistoryExpandedByUser} = useConciergeSessionState(); + const isHistoryLockedOpen = !showHiddenHistory && !isHistoryExpandedByUser; const onShowPreviousMessages = handleShowPreviousMessages; const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => getScrollOffset() >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); @@ -419,7 +422,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct <> @@ -468,7 +471,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct Date: Mon, 14 Sep 2026 06:05:48 +0200 Subject: [PATCH 038/156] Unlock history toggle --- src/hooks/useReportActionsListModel.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index b3e438fd3adb..7204dc3a1c52 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -11,6 +11,7 @@ import {reportActionsListLoadingStateSelector} from '@src/selectors/ReportMetaDa import {useRoute} from '@react-navigation/native'; +import useConciergeAskState from './useConciergeAskState'; import {useIsAppLoadPending} from './useInFlightRequests'; import useLoadReportActions from './useLoadReportActions'; import useNetworkWithOfflineStatus from './useNetworkWithOfflineStatus'; @@ -60,6 +61,7 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const hasLoadingOlderReportActionsError = reportLoadingState?.hasLoadingOlderReportActionsError; const {sessionStartTime, showFullHistory: conciergeShowFullHistory, hadMessagesAtSessionStart: conciergeHadMessagesAtSessionStart} = useConciergeSessionState(); + const {isAskConciergeChat} = useConciergeAskState(reportID); const {setShowFullHistory: setConciergeShowFullHistory, setHadMessagesAtSessionStart: setConciergeHadMessagesAtSessionStart} = useConciergeSessionActions(); const isReportTransactionThread = isReportTransactionThreadUtil(report); const shouldBeAlignedToTop = shouldReportAlignToTop(report, parentReportAction); @@ -98,7 +100,7 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea hasOlderActions, loadOlderChats, mainDMSessionStartTime: sessionStartTime, - conciergeShowFullHistory: conciergeShowFullHistory || !!reportActionIDFromRoute || !!report?.hasOutstandingChildTask, + conciergeShowFullHistory: conciergeShowFullHistory || !!reportActionIDFromRoute || (!isAskConciergeChat && !!report?.hasOutstandingChildTask), setConciergeShowFullHistory, conciergeHadMessagesAtSessionStart, setConciergeHadMessagesAtSessionStart, From 5bf9f360be974e9c1a06372c99ea3d45c5b594dd Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 14 Sep 2026 06:06:35 +0200 Subject: [PATCH 039/156] Hide returns to empty state --- src/pages/inbox/report/ConciergeChatHistoryToggle.tsx | 4 ++-- tests/unit/ConciergeChatHistoryToggleTest.tsx | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx b/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx index 303c7f1dc088..e2858c2136c4 100644 --- a/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx +++ b/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx @@ -36,7 +36,7 @@ function ConciergeChatHistoryToggle({reportID, hasPreviousMessages, shouldShowFu const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['UpArrow', 'DownArrow']); const {isAskConciergeChat} = useConciergeAskState(reportID); - const {setShowFullHistory} = useConciergeSessionActions(); + const {resetSession} = useConciergeSessionActions(); if (!isAskConciergeChat || !hasPreviousMessages) { return null; @@ -47,7 +47,7 @@ function ConciergeChatHistoryToggle({reportID, hasPreviousMessages, shouldShowFu diff --git a/src/styles/index.ts b/src/styles/index.ts index f4598c41c22a..43ef9c26211a 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -2399,29 +2399,6 @@ const staticStyles = (theme: ThemeColors) => alignSelf: 'center', }, - askConciergeEmptyStateContainer: { - alignItems: 'center', - gap: 12, - paddingHorizontal: 20, - }, - - askConciergeEmptyStateTitle: { - ...FontUtils.fontFamily.platform.EXP_NEW_KANSAS_MEDIUM, - fontSize: variables.fontSizeXLarge, - lineHeight: variables.lineHeightSizeH1, - textAlign: 'center', - color: theme.text, - }, - - askConciergeEmptyStateDescription: { - ...FontUtils.fontFamily.platform.EXP_NEUE, - fontSize: variables.fontSizeNormal, - lineHeight: variables.fontSizeNormalHeight, - textAlign: 'center', - paddingHorizontal: 20, - color: theme.textSupporting, - }, - chatItemDraft: { display: 'flex', flexDirection: 'row', diff --git a/tests/unit/ConciergeChatHistoryToggleTest.tsx b/tests/unit/ConciergeChatHistoryToggleTest.tsx index f5d45347ae58..5da646198b59 100644 --- a/tests/unit/ConciergeChatHistoryToggleTest.tsx +++ b/tests/unit/ConciergeChatHistoryToggleTest.tsx @@ -24,11 +24,11 @@ jest.mock('@hooks/useLazyAsset', () => ({ useMemoizedLazyExpensifyIcons: () => ({UpArrow: 'UpArrow', DownArrow: 'DownArrow'}), })); -const mockResetSession = jest.fn(); +const mockSetShowFullHistory = jest.fn(); jest.mock('@pages/inbox/ConciergeSessionContext', () => ({ useConciergeSessionState: () => ({sessionStartTime: '2024-06-01 12:00:00.000', showFullHistory: false, hadMessagesAtSessionStart: false}), - useConciergeSessionActions: () => ({resetSession: mockResetSession}), + useConciergeSessionActions: () => ({setShowFullHistory: mockSetShowFullHistory}), })); jest.mock('@react-navigation/native', () => ({ @@ -79,12 +79,12 @@ describe('ConciergeChatHistoryToggle', () => { expect(onShowPreviousMessages).toHaveBeenCalledTimes(1); }); - it('returns to the empty state when history is shown', () => { + it('collapses the earlier conversation when history is shown', () => { const {onShowPreviousMessages} = renderToggle({shouldShowFullHistory: true}); expect(screen.getByText('Hide chat history')).toBeTruthy(); fireEvent.press(screen.getByRole('button')); - expect(mockResetSession).toHaveBeenCalledTimes(1); + expect(mockSetShowFullHistory).toHaveBeenCalledWith(false); expect(Navigation.setParams).toHaveBeenCalledWith({reportActionID: undefined}, 'Report-test', 'ReportsSplitNavigator-test'); expect(onShowPreviousMessages).not.toHaveBeenCalled(); }); diff --git a/tests/unit/hooks/useConciergeAskState.test.tsx b/tests/unit/hooks/useConciergeAskState.test.tsx index 4c8fbf12a809..adee53b18f33 100644 --- a/tests/unit/hooks/useConciergeAskState.test.tsx +++ b/tests/unit/hooks/useConciergeAskState.test.tsx @@ -46,12 +46,41 @@ describe('useConciergeAskState', () => { mockRouteParams = {}; }); - it('shows the empty state when the session has no activity', () => { + it('shows the empty state when the session has no activity', async () => { const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); expect(result.current.isAskConciergeChat).toBe(true); expect(result.current.isHistoryExpanded).toBe(false); expect(result.current.shouldShowWelcome).toBe(true); + + // Given earlier Concierge messages that the user never replied to, such as the onboarding messages on a new account + const conciergeMessage = { + reportActionID: '5', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: CONST.ACCOUNT_ID.CONCIERGE, + created: '2024-06-01 11:00:00.000', + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[conciergeMessage.reportActionID]: conciergeMessage}); + await waitForBatchedUpdates(); + + // Then the conversation stays on screen, because `View chat history` can't bring it back + expect(result.current.shouldShowWelcome).toBe(false); + + // When the user has messaged Concierge before this session + const userMessage = { + reportActionID: '6', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: 100, + created: '2024-06-01 11:05:00.000', + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[userMessage.reportActionID]: userMessage}); + await waitForBatchedUpdates(); + + // Then the empty state hides the earlier conversation behind `View chat history` + expect(result.current.shouldShowWelcome).toBe(true); + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, null); + await waitForBatchedUpdates(); }); it('keeps the empty state for a question that opens its own thread, while sending and once sent', async () => { From fcc7c62fa47062da0091d08aae3ba9f60f1de8c8 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 12 Oct 2026 15:25:22 +0200 Subject: [PATCH 060/156] Rename the history check to fix spellcheck --- src/hooks/useConciergeAskState.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/hooks/useConciergeAskState.ts b/src/hooks/useConciergeAskState.ts index 4b0c1f475ce4..fc0cc9d285db 100644 --- a/src/hooks/useConciergeAskState.ts +++ b/src/hooks/useConciergeAskState.ts @@ -63,15 +63,15 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { }); // `View chat history` only appears once the user has messaged Concierge, so earlier messages without one stay on screen. - const hasUnhideableHistorySelector = (actions: OnyxEntry) => { + const hasHistoryWithoutUserMessageSelector = (actions: OnyxEntry) => { if (!actions || !sessionStartTime) { return false; } const previousActions = Object.values(actions).filter((action) => !isCreatedAction(action) && action.created < sessionStartTime); return previousActions.length > 0 && !previousActions.some((action) => action.actorAccountID === currentUserAccountID); }; - const [hasUnhideableHistory] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { - selector: hasUnhideableHistorySelector, + const [hasHistoryWithoutUserMessage] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + selector: hasHistoryWithoutUserMessageSelector, }); const isAskConciergeChat = !!reportID && reportID === conciergeReportID && !isInSidePanel && isBetaEnabled(CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD); @@ -81,7 +81,7 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { // A linked action opens the report at that message, so the earlier conversation is on screen either way. const isHistoryExpanded = showFullHistory || !!route.params?.reportActionID; - const shouldShowWelcome = hasSessionBoundary && !isHistoryExpanded && !hasSessionActivity && !hasUnhideableHistory; + const shouldShowWelcome = hasSessionBoundary && !isHistoryExpanded && !hasSessionActivity && !hasHistoryWithoutUserMessage; return { isAskConciergeChat, From 360eee2f59b21f7e57fd060268f4e99d977f4d5f Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 12 Oct 2026 15:32:15 +0200 Subject: [PATCH 061/156] Return a route from the useRoute mock in ReportActionsListTest --- tests/ui/ReportActionsListTest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 8f21d1f0b029..44141c6329b7 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -45,7 +45,7 @@ jest.mock('@react-navigation/native', () => { ...actualNav, // eslint-disable-next-line @typescript-eslint/no-unsafe-return useIsFocused: () => mockUseIsFocused(), - useRoute: jest.fn(), + useRoute: () => ({key: 'Report-test', name: 'Report', params: {}}), useNavigationState: jest.fn(), createNavigationContainerRef: () => ({ getState: () => jest.fn(), From d50e8b83057b04b2354fd9d406a5dfb9ffc02cef Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 13 Oct 2026 09:08:04 +0200 Subject: [PATCH 062/156] Remove the conciergeRespondInThread beta --- src/CONST/index.ts | 1 - src/components/Search/SearchRouter/useAskConcierge.tsx | 4 +--- .../inbox/report/ReportActionCompose/useComposerSubmit.ts | 4 +--- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index da41fff20baf..15f8c6213fc6 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1178,7 +1178,6 @@ const CONST = { WISE_REIMBURSEMENTS: 'wiseReimbursements', DEFAULT_LETTER_AVATARS: 'defaultLetterAvatars', TRAVEL_CODING_SYNC: 'travelCodingSync', - CONCIERGE_RESPOND_IN_THREAD: 'conciergeRespondInThread', ARCHIVE_POLICIES: 'archivePolicies', MERGE_ATS: 'mergeATSConnections', REPORT_MERGE: 'reportMerge', diff --git a/src/components/Search/SearchRouter/useAskConcierge.tsx b/src/components/Search/SearchRouter/useAskConcierge.tsx index 2f8d2360bcdf..7179a22bdb6d 100644 --- a/src/components/Search/SearchRouter/useAskConcierge.tsx +++ b/src/components/Search/SearchRouter/useAskConcierge.tsx @@ -2,7 +2,6 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useOnyx from '@hooks/useOnyx'; import useOpenConciergeAnywhere from '@hooks/useOpenConciergeAnywhere'; -import usePermissions from '@hooks/usePermissions'; import useSidePanelReportID from '@hooks/useSidePanelReportID'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -33,10 +32,9 @@ function useAskConcierge({forceConcierge = false}: {forceConcierge?: boolean} = const {timezone, accountID: currentUserAccountID} = currentUserPersonalDetails; const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); const delegateAccountID = useDelegateAccountID(); - const {isBetaEnabled} = usePermissions(); const shouldShowAskConcierge = !!targetReportID && !!targetReport; - const shouldRespondInThread = targetReportID === conciergeReportID && isBetaEnabled(CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD); + const shouldRespondInThread = targetReportID === conciergeReportID; const askConcierge = (searchQuery: string) => { const trimmedQuery = searchQuery.trim(); diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts index f3664da4423f..0c54bd0f10a7 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts @@ -3,7 +3,6 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useIsInSidePanel from '@hooks/useIsInSidePanel'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useSidePanelActions from '@hooks/useSidePanelActions'; @@ -38,7 +37,6 @@ function useComposerSubmit(reportID: string) { const route = useRoute(); const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - const {isBetaEnabled} = usePermissions(); const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); const delegateAccountID = useDelegateAccountID(); @@ -80,7 +78,7 @@ function useComposerSubmit(reportID: string) { clearAgentZeroProcessingIndicator(reportID, CONST.ACCOUNT_ID.CONCIERGE); } - const shouldRespondInThread = reportID === conciergeReportID && isBetaEnabled(CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD); + const shouldRespondInThread = reportID === conciergeReportID; if (attachmentFileRef.current) { const attachments = attachmentFileRef.current; From 3233984851cce01c7bbc102d86a763494621dc5e Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 13 Oct 2026 09:08:21 +0200 Subject: [PATCH 063/156] Drop the conciergeRespondInThread beta checks from the Ask Concierge screen --- src/hooks/useConciergeAskState.ts | 4 +--- src/pages/inbox/HeaderView.tsx | 4 +--- src/pages/inbox/report/ShowPreviousMessagesButton.tsx | 4 +--- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/hooks/useConciergeAskState.ts b/src/hooks/useConciergeAskState.ts index fc0cc9d285db..c8c229397c15 100644 --- a/src/hooks/useConciergeAskState.ts +++ b/src/hooks/useConciergeAskState.ts @@ -16,7 +16,6 @@ import {useRoute} from '@react-navigation/native'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useIsInSidePanel from './useIsInSidePanel'; import useOnyx from './useOnyx'; -import usePermissions from './usePermissions'; import useResponsiveLayout from './useResponsiveLayout'; type ConciergeAskState = { @@ -37,7 +36,6 @@ type ConciergeAskState = { }; function useConciergeAskState(reportID: string | undefined): ConciergeAskState { - const {isBetaEnabled} = usePermissions(); const isInSidePanel = useIsInSidePanel(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const route = useRoute>(); @@ -74,7 +72,7 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { selector: hasHistoryWithoutUserMessageSelector, }); - const isAskConciergeChat = !!reportID && reportID === conciergeReportID && !isInSidePanel && isBetaEnabled(CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD); + const isAskConciergeChat = !!reportID && reportID === conciergeReportID && !isInSidePanel; const hasSessionBoundary = isAskConciergeChat && !!sessionStartTime; diff --git a/src/pages/inbox/HeaderView.tsx b/src/pages/inbox/HeaderView.tsx index 7e8a14240dd7..6bd032085568 100644 --- a/src/pages/inbox/HeaderView.tsx +++ b/src/pages/inbox/HeaderView.tsx @@ -27,7 +27,6 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useParentReportAction from '@hooks/useParentReportAction'; -import usePermissions from '@hooks/usePermissions'; import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePolicy from '@hooks/usePolicy'; @@ -157,9 +156,8 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const isSelfDM = isSelfDMReportUtils(report); const isGroupChat = isGroupChatReportUtils(report) || isDeprecatedGroupDM(report, isReportArchived); const isConciergeChat = isConciergeChatReport(report, conciergeReportID); - const {isBetaEnabled} = usePermissions(); const {resetSession: resetConciergeSession} = useConciergeSessionActions(); - const isAskConciergeEnabled = isBetaEnabled(CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD) && !isInSidePanel; + const isAskConciergeEnabled = !isInSidePanel; const isConciergeThread = !!conciergeReportID && report?.parentReportID === conciergeReportID; const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [onboarding] = useOnyx(ONYXKEYS.NVP_ONBOARDING); diff --git a/src/pages/inbox/report/ShowPreviousMessagesButton.tsx b/src/pages/inbox/report/ShowPreviousMessagesButton.tsx index a59023504186..263cd79fcdec 100644 --- a/src/pages/inbox/report/ShowPreviousMessagesButton.tsx +++ b/src/pages/inbox/report/ShowPreviousMessagesButton.tsx @@ -4,7 +4,6 @@ import useIsInSidePanel from '@hooks/useIsInSidePanel'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; @@ -32,7 +31,6 @@ function ShowPreviousMessagesButton({reportID, actionType, hasPreviousMessages, const styles = useThemeStyles(); const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['UpArrow']); - const {isBetaEnabled} = usePermissions(); const isInSidePanel = useIsInSidePanel(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const isConciergeChat = reportID === conciergeReportID; @@ -41,7 +39,7 @@ function ShowPreviousMessagesButton({reportID, actionType, hasPreviousMessages, return null; } // ConciergeChatHistoryToggle takes over in the Ask Concierge design, where the control also collapses the history again. - if (!isInSidePanel && isBetaEnabled(CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD)) { + if (!isInSidePanel) { return null; } if (!onPress) { From f2e0cca8afebb55ddbf2f5d10d5f181f02e41cd2 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 13 Oct 2026 09:08:47 +0200 Subject: [PATCH 064/156] Update Ask Concierge tests for the removed beta --- tests/unit/ConciergeChatHistoryToggleTest.tsx | 12 --------- tests/unit/ShowPreviousMessagesButtonTest.tsx | 26 ++----------------- .../unit/hooks/useConciergeAskState.test.tsx | 12 --------- 3 files changed, 2 insertions(+), 48 deletions(-) diff --git a/tests/unit/ConciergeChatHistoryToggleTest.tsx b/tests/unit/ConciergeChatHistoryToggleTest.tsx index 5da646198b59..5120ce7bf25e 100644 --- a/tests/unit/ConciergeChatHistoryToggleTest.tsx +++ b/tests/unit/ConciergeChatHistoryToggleTest.tsx @@ -63,7 +63,6 @@ describe('ConciergeChatHistoryToggle', () => { jest.spyOn(Navigation, 'setParams').mockImplementation(() => {}); await IntlStore.load(CONST.LOCALES.EN); await Onyx.set(ONYXKEYS.CONCIERGE_REPORT_ID, CONCIERGE_REPORT_ID); - await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD]); await waitForBatchedUpdates(); }); @@ -93,15 +92,4 @@ describe('ConciergeChatHistoryToggle', () => { renderToggle({hasPreviousMessages: false}); expect(screen.queryByRole('button')).toBeNull(); }); - - it('renders nothing when the Ask Concierge beta is disabled', async () => { - await Onyx.set(ONYXKEYS.BETAS, []); - await waitForBatchedUpdates(); - - renderToggle(); - expect(screen.queryByRole('button')).toBeNull(); - - await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD]); - await waitForBatchedUpdates(); - }); }); diff --git a/tests/unit/ShowPreviousMessagesButtonTest.tsx b/tests/unit/ShowPreviousMessagesButtonTest.tsx index 84f1a787acaf..ddfd44b7011c 100644 --- a/tests/unit/ShowPreviousMessagesButtonTest.tsx +++ b/tests/unit/ShowPreviousMessagesButtonTest.tsx @@ -67,9 +67,9 @@ describe('ShowPreviousMessagesButton', () => { expect(onPress).toHaveBeenCalledTimes(1); }); - it('renders the button when not in the side panel (main DM)', () => { + it('renders nothing when not in the side panel (main DM)', () => { renderButton({}, {isInSidePanel: false}); - expect(screen.getByRole('button')).toBeTruthy(); + expect(screen.queryByRole('button')).toBeNull(); }); it('renders nothing when reportID does not match the Concierge report', () => { @@ -96,26 +96,4 @@ describe('ShowPreviousMessagesButton', () => { renderButton({showFullHistory: true}); expect(screen.queryByRole('button')).toBeNull(); }); - - it('renders nothing in the main DM when the Ask Concierge beta is enabled', async () => { - await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD]); - await waitForBatchedUpdates(); - - renderButton({}, {isInSidePanel: false}); - expect(screen.queryByRole('button')).toBeNull(); - - await Onyx.set(ONYXKEYS.BETAS, []); - await waitForBatchedUpdates(); - }); - - it('renders the button in the side panel when the Ask Concierge beta is enabled', async () => { - await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD]); - await waitForBatchedUpdates(); - - renderButton(); - expect(screen.getByRole('button')).toBeTruthy(); - - await Onyx.set(ONYXKEYS.BETAS, []); - await waitForBatchedUpdates(); - }); }); diff --git a/tests/unit/hooks/useConciergeAskState.test.tsx b/tests/unit/hooks/useConciergeAskState.test.tsx index adee53b18f33..5a9f9d1c0194 100644 --- a/tests/unit/hooks/useConciergeAskState.test.tsx +++ b/tests/unit/hooks/useConciergeAskState.test.tsx @@ -38,7 +38,6 @@ describe('useConciergeAskState', () => { beforeAll(async () => { Onyx.init({keys: ONYXKEYS}); await Onyx.set(ONYXKEYS.CONCIERGE_REPORT_ID, CONCIERGE_REPORT_ID); - await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD]); await waitForBatchedUpdates(); }); @@ -136,15 +135,4 @@ describe('useConciergeAskState', () => { expect(result.current.isAskConciergeChat).toBe(false); expect(result.current.shouldShowWelcome).toBe(false); }); - - it('stays off when the Ask Concierge beta is disabled', async () => { - await Onyx.set(ONYXKEYS.BETAS, []); - await waitForBatchedUpdates(); - - const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); - expect(result.current.isAskConciergeChat).toBe(false); - - await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.CONCIERGE_RESPOND_IN_THREAD]); - await waitForBatchedUpdates(); - }); }); From e43443115b5835ed60fad6ce824d7a3e8aeabe5d Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 13 Oct 2026 09:18:05 +0200 Subject: [PATCH 065/156] Hide the Concierge greeting in the main Concierge chat --- src/hooks/useConciergeSidePanelReportActions.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/hooks/useConciergeSidePanelReportActions.ts b/src/hooks/useConciergeSidePanelReportActions.ts index 7ca638fa6997..ad6c323358cf 100644 --- a/src/hooks/useConciergeSidePanelReportActions.ts +++ b/src/hooks/useConciergeSidePanelReportActions.ts @@ -68,7 +68,6 @@ function useConciergeSidePanelReportActions({ // them sticky rather than re-deriving visibility from the timestamp. const [sessionSentActionIDs, setSessionSentActionIDs] = useState>(() => new Set()); - const hadMessagesAtSessionStart = localHadMessagesAtSessionStart; const showFullHistory = externalShowFullHistory ?? localShowFullHistory; const setShowFullHistory = onSetShowFullHistory ?? setLocalShowFullHistory; @@ -136,7 +135,7 @@ function useConciergeSidePanelReportActions({ // A re-stamp flips `hasUserSentMessage` too, so the sticky set is what keeps the welcome state from coming back. const hasSentInSession = hasUserSentMessage || sessionSentActionIDs.size > 0; const showConciergeSidePanelWelcome = isConciergeHiddenHistory && hadUserMessageAtSessionStart && !hasSentInSession && !showFullHistory && !hasMessagesInSession && !hasOpenChildTask; - const showConciergeGreeting = isConciergeHiddenHistory && hadUserMessageAtSessionStart && !showFullHistory && (!isConciergeMainDM || !hadMessagesAtSessionStart); + const showConciergeGreeting = isConciergeHiddenHistory && hadUserMessageAtSessionStart && !showFullHistory && !isConciergeMainDM; const conciergeGreetingAction = useMemo(() => { if (!showConciergeGreeting) { From 840e6e0625ebd579e8a8b6c306c037ccf192ec82 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 13 Oct 2026 09:21:00 +0200 Subject: [PATCH 066/156] Drop the beta from the Ask Concierge state doc comment --- src/hooks/useConciergeAskState.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useConciergeAskState.ts b/src/hooks/useConciergeAskState.ts index c8c229397c15..31302bc51adc 100644 --- a/src/hooks/useConciergeAskState.ts +++ b/src/hooks/useConciergeAskState.ts @@ -19,7 +19,7 @@ import useOnyx from './useOnyx'; import useResponsiveLayout from './useResponsiveLayout'; type ConciergeAskState = { - /** Whether this is the Concierge chat in the Inbox and the beta is on */ + /** Whether this is the Concierge chat in the Inbox */ isAskConciergeChat: boolean; /** Whether the messages from before this session are shown */ From ec355e8c8de6f847e07f52bb241d0d5aaf21cda2 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 13 Oct 2026 09:21:47 +0200 Subject: [PATCH 067/156] Revert "Hide the Concierge greeting in the main Concierge chat" This reverts commit e43443115b5835ed60fad6ce824d7a3e8aeabe5d. --- src/hooks/useConciergeSidePanelReportActions.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hooks/useConciergeSidePanelReportActions.ts b/src/hooks/useConciergeSidePanelReportActions.ts index ad6c323358cf..7ca638fa6997 100644 --- a/src/hooks/useConciergeSidePanelReportActions.ts +++ b/src/hooks/useConciergeSidePanelReportActions.ts @@ -68,6 +68,7 @@ function useConciergeSidePanelReportActions({ // them sticky rather than re-deriving visibility from the timestamp. const [sessionSentActionIDs, setSessionSentActionIDs] = useState>(() => new Set()); + const hadMessagesAtSessionStart = localHadMessagesAtSessionStart; const showFullHistory = externalShowFullHistory ?? localShowFullHistory; const setShowFullHistory = onSetShowFullHistory ?? setLocalShowFullHistory; @@ -135,7 +136,7 @@ function useConciergeSidePanelReportActions({ // A re-stamp flips `hasUserSentMessage` too, so the sticky set is what keeps the welcome state from coming back. const hasSentInSession = hasUserSentMessage || sessionSentActionIDs.size > 0; const showConciergeSidePanelWelcome = isConciergeHiddenHistory && hadUserMessageAtSessionStart && !hasSentInSession && !showFullHistory && !hasMessagesInSession && !hasOpenChildTask; - const showConciergeGreeting = isConciergeHiddenHistory && hadUserMessageAtSessionStart && !showFullHistory && !isConciergeMainDM; + const showConciergeGreeting = isConciergeHiddenHistory && hadUserMessageAtSessionStart && !showFullHistory && (!isConciergeMainDM || !hadMessagesAtSessionStart); const conciergeGreetingAction = useMemo(() => { if (!showConciergeGreeting) { From 6d108a8c136fd4a2be961c2d8cccfb950c895063 Mon Sep 17 00:00:00 2001 From: "Rushat Gabhane (via MelvinBot)" Date: Wed, 14 Oct 2026 00:32:14 +0000 Subject: [PATCH 068/156] Update useAskConcierge tests for removed Concierge thread beta Co-authored-by: Rushat Gabhane --- tests/unit/hooks/useAskConcierge.test.tsx | 48 +++++++---------------- 1 file changed, 14 insertions(+), 34 deletions(-) diff --git a/tests/unit/hooks/useAskConcierge.test.tsx b/tests/unit/hooks/useAskConcierge.test.tsx index 943fdf4f3cc1..8a05a3b58b4a 100644 --- a/tests/unit/hooks/useAskConcierge.test.tsx +++ b/tests/unit/hooks/useAskConcierge.test.tsx @@ -19,7 +19,6 @@ const ADMINS_ROOM_REPORT_ID = '200'; let mockSidePanelReportID: string | undefined; let mockIsInSidePanel = false; -let mockIsBetaEnabled = false; const mockOpenConciergeAnywhere = jest.fn(); jest.mock('@hooks/useSidePanelReportID', () => ({ @@ -42,11 +41,6 @@ jest.mock('@hooks/useDelegateAccountID', () => ({ default: () => '99', })); -jest.mock('@hooks/usePermissions', () => ({ - __esModule: true, - default: () => ({isBetaEnabled: () => mockIsBetaEnabled, isBetaEnabledOrUnknown: () => mockIsBetaEnabled}), -})); - jest.mock('@userActions/Report', () => ({ addComment: jest.fn(), addAttachmentWithComment: jest.fn(), @@ -80,7 +74,6 @@ describe('useAskConcierge', () => { beforeEach(async () => { mockSidePanelReportID = undefined; mockIsInSidePanel = false; - mockIsBetaEnabled = false; jest.clearAllMocks(); await Onyx.clear(); }); @@ -126,7 +119,8 @@ describe('useAskConcierge', () => { describe('askConcierge', () => { it('opens Concierge and sends the trimmed message to the resolved report', async () => { - // Given a loaded Concierge report + // Given web, with a loaded Concierge report + mockIsInSidePanel = true; await seedReports(); const {result} = renderAskConcierge({forceConcierge: true}); expect(result.current.shouldShowAskConcierge).toBe(true); @@ -134,8 +128,10 @@ describe('useAskConcierge', () => { // When a padded message is sent result.current.askConcierge(' Where is my expense? '); - // Then Concierge is opened and the trimmed text is added as a comment - expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true}); + // Then Concierge is opened on the new thread and the trimmed text is added as a comment + const conciergeThreadReportID = mockAddComment.mock.calls.at(0)?.at(0)?.conciergeThreadReportID; + expect(conciergeThreadReportID).toEqual(expect.any(String)); + expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true, reportID: conciergeThreadReportID}); expect(mockAddComment).toHaveBeenCalledWith( expect.objectContaining({ report: CONCIERGE_REPORT, @@ -144,7 +140,7 @@ describe('useAskConcierge', () => { currentUserAccountID: ACCOUNT_ID, timezoneParam: {selected: TIMEZONE}, shouldPlaySound: true, - isInSidePanel: false, + isInSidePanel: true, delegateAccountID: DELEGATE_ACCOUNT_ID, conciergeReportID: CONCIERGE_REPORT_ID, }), @@ -201,7 +197,8 @@ describe('useAskConcierge', () => { const FILES: FileObject[] = [{name: 'receipt.jpg', type: 'image/jpeg', uri: 'file://receipt.jpg'}]; it('opens Concierge and sends the attachments with the trimmed text', async () => { - // Given a loaded Concierge report + // Given web, with a loaded Concierge report + mockIsInSidePanel = true; await seedReports(); const {result} = renderAskConcierge({forceConcierge: true}); expect(result.current.shouldShowAskConcierge).toBe(true); @@ -209,8 +206,10 @@ describe('useAskConcierge', () => { // When attachments are sent with a padded caption result.current.askConciergeWithAttachment(FILES, ' Here it is '); - // Then Concierge is opened and the attachments are added with the trimmed text - expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true}); + // Then Concierge is opened on the new thread and the attachments are added with the trimmed text + const conciergeThreadReportID = mockAddAttachmentWithComment.mock.calls.at(0)?.at(0)?.conciergeThreadReportID; + expect(conciergeThreadReportID).toEqual(expect.any(String)); + expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true, reportID: conciergeThreadReportID}); expect(mockAddAttachmentWithComment).toHaveBeenCalledWith( expect.objectContaining({ report: CONCIERGE_REPORT, @@ -220,7 +219,7 @@ describe('useAskConcierge', () => { currentUserAccountID: ACCOUNT_ID, timezone: {selected: TIMEZONE}, shouldPlaySound: true, - isInSidePanel: false, + isInSidePanel: true, delegateAccountID: DELEGATE_ACCOUNT_ID, conciergeReportID: CONCIERGE_REPORT_ID, }), @@ -265,7 +264,6 @@ describe('useAskConcierge', () => { it('shows the new thread in the side panel instead of sending the user to the Inbox', async () => { // Given web, where Concierge answers each question in its own thread mockIsInSidePanel = true; - mockIsBetaEnabled = true; await seedReports(); const {result} = renderAskConcierge({forceConcierge: true}); expect(result.current.shouldShowAskConcierge).toBe(true); @@ -284,7 +282,6 @@ describe('useAskConcierge', () => { it('sends the user to the thread on native, where there is no side panel', async () => { // Given native, where Concierge answers each question in its own thread - mockIsBetaEnabled = true; await seedReports(); const {result} = renderAskConcierge({forceConcierge: true}); expect(result.current.shouldShowAskConcierge).toBe(true); @@ -298,25 +295,9 @@ describe('useAskConcierge', () => { expect(mockOpenConciergeAnywhere).not.toHaveBeenCalled(); }); - it('opens the Concierge chat itself when the beta is off', async () => { - // Given web without the thread beta - mockIsInSidePanel = true; - await seedReports(); - const {result} = renderAskConcierge({forceConcierge: true}); - expect(result.current.shouldShowAskConcierge).toBe(true); - - // When a question is asked - result.current.askConcierge('Where is my expense?'); - - // Then no thread is created and the side panel falls back to the Concierge chat - expect(mockAddComment).toHaveBeenCalledWith(expect.objectContaining({conciergeThreadReportID: undefined})); - expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true, reportID: undefined}); - }); - it('shows the thread in the side panel for a single attachment', async () => { // Given web, where Concierge answers each question in its own thread mockIsInSidePanel = true; - mockIsBetaEnabled = true; await seedReports(); const {result} = renderAskConcierge({forceConcierge: true}); expect(result.current.shouldShowAskConcierge).toBe(true); @@ -333,7 +314,6 @@ describe('useAskConcierge', () => { it('keeps several attachments in the Concierge chat', async () => { // Given web, where Concierge answers each question in its own thread mockIsInSidePanel = true; - mockIsBetaEnabled = true; await seedReports(); const {result} = renderAskConcierge({forceConcierge: true}); expect(result.current.shouldShowAskConcierge).toBe(true); From 78f08c19ec170a45b0d41cb7003078899401c019 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 14 Oct 2026 19:55:57 +0200 Subject: [PATCH 069/156] Show the New chat button only on Concierge chat threads --- src/pages/inbox/HeaderView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/HeaderView.tsx b/src/pages/inbox/HeaderView.tsx index 6bd032085568..2c5f33ed7998 100644 --- a/src/pages/inbox/HeaderView.tsx +++ b/src/pages/inbox/HeaderView.tsx @@ -158,7 +158,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const isConciergeChat = isConciergeChatReport(report, conciergeReportID); const {resetSession: resetConciergeSession} = useConciergeSessionActions(); const isAskConciergeEnabled = !isInSidePanel; - const isConciergeThread = !!conciergeReportID && report?.parentReportID === conciergeReportID; + const isConciergeThread = !!conciergeReportID && isChatThreadReportUtils(report) && report.parentReportID === conciergeReportID; const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [onboarding] = useOnyx(ONYXKEYS.NVP_ONBOARDING); const allParticipants = getParticipantsAccountIDsForDisplay(report, false, true, undefined, reportMetadata); From 9c9df7023829b567cbc432154bd8761522c8ec5a Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 14 Oct 2026 19:56:37 +0200 Subject: [PATCH 070/156] Keep the Concierge welcome after creating a task in the chat --- src/hooks/useConciergeAskState.ts | 5 ++--- tests/unit/hooks/useConciergeAskState.test.tsx | 7 +++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/hooks/useConciergeAskState.ts b/src/hooks/useConciergeAskState.ts index 31302bc51adc..76bd98677fc4 100644 --- a/src/hooks/useConciergeAskState.ts +++ b/src/hooks/useConciergeAskState.ts @@ -4,7 +4,6 @@ import {isCreatedAction, isCurrentUserPendingAddAction} from '@libs/ReportAction import {useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type {ReportActions} from '@src/types/onyx/ReportAction'; @@ -48,9 +47,9 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { return false; } return Object.values(actions).some((action) => { - // A question that opens its own thread is answered there, so it keeps the welcome both while sending and once sent. + // An action that opens its own report, like a question's thread or a task, keeps the welcome both while sending and once sent. // Otherwise reconnecting would switch this chat to a partial view that `Hide chat history` can't return to. - if (action.actorAccountID === currentUserAccountID && !!action.childReportID && action.childType === CONST.REPORT.TYPE.CHAT) { + if (action.actorAccountID === currentUserAccountID && !!action.childReportID) { return false; } return isCurrentUserPendingAddAction(action, currentUserAccountID) || (!isCreatedAction(action) && action.created >= sessionStartTime); diff --git a/tests/unit/hooks/useConciergeAskState.test.tsx b/tests/unit/hooks/useConciergeAskState.test.tsx index 5a9f9d1c0194..c2fe1ca4d000 100644 --- a/tests/unit/hooks/useConciergeAskState.test.tsx +++ b/tests/unit/hooks/useConciergeAskState.test.tsx @@ -116,6 +116,13 @@ describe('useConciergeAskState', () => { // Then the question counts as activity, so its error stays visible in this chat expect(result.current.shouldShowWelcome).toBe(false); + // When the current user creates a task in the Concierge chat + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[question.reportActionID]: {childReportID: '5', childType: CONST.REPORT.TYPE.TASK}}); + await waitForBatchedUpdates(); + + // Then the welcome stays, like it does for a question + expect(result.current.shouldShowWelcome).toBe(true); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, null); await waitForBatchedUpdates(); }); From 8a96c8db55546a49ed61627a7fe817b9e8935c73 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 14 Oct 2026 19:59:03 +0200 Subject: [PATCH 071/156] Revert "Show the New chat button only on Concierge chat threads" This reverts commit 78f08c19ec170a45b0d41cb7003078899401c019. --- src/pages/inbox/HeaderView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/HeaderView.tsx b/src/pages/inbox/HeaderView.tsx index 2c5f33ed7998..6bd032085568 100644 --- a/src/pages/inbox/HeaderView.tsx +++ b/src/pages/inbox/HeaderView.tsx @@ -158,7 +158,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const isConciergeChat = isConciergeChatReport(report, conciergeReportID); const {resetSession: resetConciergeSession} = useConciergeSessionActions(); const isAskConciergeEnabled = !isInSidePanel; - const isConciergeThread = !!conciergeReportID && isChatThreadReportUtils(report) && report.parentReportID === conciergeReportID; + const isConciergeThread = !!conciergeReportID && report?.parentReportID === conciergeReportID; const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [onboarding] = useOnyx(ONYXKEYS.NVP_ONBOARDING); const allParticipants = getParticipantsAccountIDsForDisplay(report, false, true, undefined, reportMetadata); From 78c1e5d49c8ad17db4ecd8ecb9167b69e2ef0f78 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 14 Oct 2026 20:01:45 +0200 Subject: [PATCH 072/156] Document the Concierge tests with Given, When and Then --- tests/unit/AskConciergeEmptyStateTest.tsx | 3 +++ tests/unit/ConciergeChatHistoryToggleTest.tsx | 16 ++++++++++++++-- tests/unit/ConciergeSessionContextTest.tsx | 6 ++++-- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/unit/AskConciergeEmptyStateTest.tsx b/tests/unit/AskConciergeEmptyStateTest.tsx index 188894082337..248a9e09a305 100644 --- a/tests/unit/AskConciergeEmptyStateTest.tsx +++ b/tests/unit/AskConciergeEmptyStateTest.tsx @@ -23,6 +23,8 @@ describe('AskConciergeEmptyState', () => { }); it('renders the Ask Concierge prompt', () => { + // Given the empty state shown in place of the Concierge chat's messages + // When it renders render( @@ -31,6 +33,7 @@ describe('AskConciergeEmptyState', () => { , ); + // Then it invites the user to ask Concierge a question expect(screen.getByTestId('AskConciergeEmptyState')).toBeTruthy(); expect(screen.getByText('Ask me anything!')).toBeTruthy(); expect(screen.getByText('Concierge can answer questions, update expenses, and more.')).toBeTruthy(); diff --git a/tests/unit/ConciergeChatHistoryToggleTest.tsx b/tests/unit/ConciergeChatHistoryToggleTest.tsx index 5120ce7bf25e..c657b97314af 100644 --- a/tests/unit/ConciergeChatHistoryToggleTest.tsx +++ b/tests/unit/ConciergeChatHistoryToggleTest.tsx @@ -71,25 +71,37 @@ describe('ConciergeChatHistoryToggle', () => { }); it('reveals the earlier conversation when history is hidden', () => { + // Given the earlier conversation is hidden const {onShowPreviousMessages} = renderToggle(); - expect(screen.getByText('View chat history')).toBeTruthy(); + + // When the user presses the toggle fireEvent.press(screen.getByRole('button')); + + // Then the earlier conversation is revealed expect(onShowPreviousMessages).toHaveBeenCalledTimes(1); }); it('collapses the earlier conversation when history is shown', () => { + // Given the earlier conversation is shown const {onShowPreviousMessages} = renderToggle({shouldShowFullHistory: true}); - expect(screen.getByText('Hide chat history')).toBeTruthy(); + + // When the user presses the toggle fireEvent.press(screen.getByRole('button')); + + // Then the history collapses and the linked message is cleared, so the chat goes back to its welcome state expect(mockSetShowFullHistory).toHaveBeenCalledWith(false); expect(Navigation.setParams).toHaveBeenCalledWith({reportActionID: undefined}, 'Report-test', 'ReportsSplitNavigator-test'); expect(onShowPreviousMessages).not.toHaveBeenCalled(); }); it('renders nothing when there is no earlier conversation to reveal', () => { + // Given a Concierge chat with no earlier conversation + // When the toggle renders renderToggle({hasPreviousMessages: false}); + + // Then there is nothing to toggle, so no button shows expect(screen.queryByRole('button')).toBeNull(); }); }); diff --git a/tests/unit/ConciergeSessionContextTest.tsx b/tests/unit/ConciergeSessionContextTest.tsx index ab036a2f3281..96a02570aaf4 100644 --- a/tests/unit/ConciergeSessionContextTest.tsx +++ b/tests/unit/ConciergeSessionContextTest.tsx @@ -17,15 +17,17 @@ describe('ConciergeSessionProvider', () => { }); it('keeps the New chat boundary when the unread anchor resolves again', () => { + // Given a session that starts at the unread boundary const {result} = renderHook(() => ({...useConciergeSessionState(), ...useConciergeSessionActions()}), {wrapper}); - act(() => result.current.startSession(UNREAD_BOUNDARY)); expect(result.current.sessionStartTime).toBe(UNREAD_BOUNDARY); + // When the user presses New chat and the unread boundary resolves again act(() => result.current.resetSession()); const boundaryAfterReset = result.current.sessionStartTime; - act(() => result.current.startSession(UNREAD_BOUNDARY)); + + // Then the session keeps the New chat boundary, so earlier messages stay hidden expect(result.current.sessionStartTime).toBe(boundaryAfterReset); }); }); From ef562c5d3c68c95a68fe4d2cd4ff6ab14b0b4030 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 14 Oct 2026 20:02:12 +0200 Subject: [PATCH 073/156] Keep the Concierge welcome while older messages are still unloaded --- src/hooks/useConciergeAskState.ts | 8 +++++++- tests/unit/hooks/useConciergeAskState.test.tsx | 14 +++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/hooks/useConciergeAskState.ts b/src/hooks/useConciergeAskState.ts index 76bd98677fc4..5fc0be61fc31 100644 --- a/src/hooks/useConciergeAskState.ts +++ b/src/hooks/useConciergeAskState.ts @@ -64,7 +64,13 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { if (!actions || !sessionStartTime) { return false; } - const previousActions = Object.values(actions).filter((action) => !isCreatedAction(action) && action.created < sessionStartTime); + const allActions = Object.values(actions); + + // Until the chat's first action is loaded, the user's earlier messages may be on an older page. + if (!allActions.some((action) => isCreatedAction(action))) { + return false; + } + const previousActions = allActions.filter((action) => !isCreatedAction(action) && action.created < sessionStartTime); return previousActions.length > 0 && !previousActions.some((action) => action.actorAccountID === currentUserAccountID); }; const [hasHistoryWithoutUserMessage] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { diff --git a/tests/unit/hooks/useConciergeAskState.test.tsx b/tests/unit/hooks/useConciergeAskState.test.tsx index c2fe1ca4d000..1b52ff2fe264 100644 --- a/tests/unit/hooks/useConciergeAskState.test.tsx +++ b/tests/unit/hooks/useConciergeAskState.test.tsx @@ -52,7 +52,7 @@ describe('useConciergeAskState', () => { expect(result.current.isHistoryExpanded).toBe(false); expect(result.current.shouldShowWelcome).toBe(true); - // Given earlier Concierge messages that the user never replied to, such as the onboarding messages on a new account + // Given the newest page of earlier Concierge messages, with older pages not loaded yet const conciergeMessage = { reportActionID: '5', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, @@ -62,6 +62,18 @@ describe('useConciergeAskState', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[conciergeMessage.reportActionID]: conciergeMessage}); await waitForBatchedUpdates(); + // Then the empty state stays, because the user may have replied on an older page + expect(result.current.shouldShowWelcome).toBe(true); + + // When the whole chat is loaded and the user never replied, such as the onboarding messages on a new account + const createdAction = { + reportActionID: '7', + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + created: '2024-06-01 10:00:00.000', + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[createdAction.reportActionID]: createdAction}); + await waitForBatchedUpdates(); + // Then the conversation stays on screen, because `View chat history` can't bring it back expect(result.current.shouldShowWelcome).toBe(false); From f3acff14f3c1cbf9cdf9584d76f599a6bbd682d5 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 14 Oct 2026 20:04:50 +0200 Subject: [PATCH 074/156] Drop manual memoization from resetSession --- src/pages/inbox/ConciergeSessionContext.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/ConciergeSessionContext.tsx b/src/pages/inbox/ConciergeSessionContext.tsx index 523dd5964b31..bed4a07da113 100644 --- a/src/pages/inbox/ConciergeSessionContext.tsx +++ b/src/pages/inbox/ConciergeSessionContext.tsx @@ -105,13 +105,13 @@ function ConciergeSessionProvider({children}: PropsWithChildren) { [sessionStartTime], ); - const resetSession = useCallback(() => { + const resetSession = () => { sessionCreatedAtRef.current = Date.now(); isSessionResetByUserRef.current = true; setSessionStartTime(getServerAnchoredDBTime()); setShowFullHistory(false); setHadMessagesAtSessionStart(false); - }, []); + }; const stateValue = useMemo(() => ({sessionStartTime, showFullHistory, hadMessagesAtSessionStart}), [sessionStartTime, showFullHistory, hadMessagesAtSessionStart]); const actionsValue = useMemo(() => ({startSession, resetSession, setShowFullHistory, setHadMessagesAtSessionStart}), [startSession, resetSession]); From a71596c576ad3c5e6c223627ba3eb3ef10ae3244 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 14 Oct 2026 20:27:44 +0200 Subject: [PATCH 075/156] Fix the grammar of the reimbursement prompt --- src/languages/en.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index 1769fd3a2635..111ddb922f48 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -610,7 +610,7 @@ const translations = { topSpender: 'Who spent the most this month?', missingCategory: 'Which expenses are missing a category?', awaitingApproval: 'Which reports are waiting on my approval?', - notReimbursed: "What haven't I been reimbursed yet?", + notReimbursed: "What haven't I been reimbursed for yet?", unsubmitted: 'Which of my expenses are still unsubmitted?', leftToPay: "What's left to pay this month?", createExpense: 'Create an expense for a $30 lunch', From 6914c257f23e65fbd905456b791538f778764c36 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 14 Oct 2026 20:29:37 +0200 Subject: [PATCH 076/156] Show the chat history after creating a task in Concierge --- .../inbox/report/ReportActionCompose/useComposerSubmit.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts index 0c54bd0f10a7..c5f17364007c 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts @@ -17,6 +17,7 @@ import getSendMessageSource from '@libs/telemetry/getSendMessageSource'; import {getSendMessageVisibleSpanID} from '@libs/telemetry/sendMessageSpans'; import {useActionListContext} from '@pages/inbox/ActionListContext'; +import {useConciergeSessionActions} from '@pages/inbox/ConciergeSessionContext'; import {setIsComposerFullSize} from '@userActions/Report'; @@ -46,6 +47,7 @@ function useComposerSubmit(reportID: string) { const {isEditingInComposer, effectiveDraft, didResetComposerHeightWhileEditing, editingState} = useComposerEditState(); const {publishDraft, setDidResetComposerHeightWhileEditing} = useComposerEditActions(); const {scrollOffsetRef} = useActionListContext(); + const {setShowFullHistory} = useConciergeSessionActions(); const {report, effectiveTransactionThreadReportID} = useComposerReportData(reportID); const [targetReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${effectiveTransactionThreadReportID ?? reportID}`); @@ -108,6 +110,9 @@ function useComposerSubmit(reportID: string) { } if (createTaskFromMarkdown({text: draftMessageTrimmed, parentReport: report, currentUserPersonalDetails, quickAction, delegateAccountID, ancestors: reportAncestors})) { + if (shouldRespondInThread && !isInSidePanel) { + setShowFullHistory(true); + } return; } From 7b281901a7b8703ef8cda2583afa5480d2e904ea Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 16 Oct 2026 13:43:19 +0200 Subject: [PATCH 077/156] Show the chat history after sending several attachments to Concierge --- .../inbox/report/ReportActionCompose/useComposerSubmit.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts index c5f17364007c..1cb61d69feb7 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts @@ -105,6 +105,9 @@ function useComposerSubmit(reportID: string) { if (conciergeThreadReportID && isInSidePanel) { openSidePanel({reportID: conciergeThreadReportID}); } + if (shouldRespondInThread && !willOpenThread && !isInSidePanel) { + setShowFullHistory(true); + } attachmentFileRef.current = null; return; } From 47357649ec51a3687654d3784a8f22c1604ccafa Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 16 Oct 2026 13:45:52 +0200 Subject: [PATCH 078/156] Keep an open Concierge task on screen instead of the welcome --- src/hooks/useConciergeAskState.ts | 6 ++++++ .../useConciergeSidePanelReportActions.ts | 1 + .../unit/hooks/useConciergeAskState.test.tsx | 19 ++++++++++++++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/hooks/useConciergeAskState.ts b/src/hooks/useConciergeAskState.ts index 5fc0be61fc31..0933d6b0430e 100644 --- a/src/hooks/useConciergeAskState.ts +++ b/src/hooks/useConciergeAskState.ts @@ -12,6 +12,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {useRoute} from '@react-navigation/native'; +import {isOpenChildTaskAction} from './useConciergeSidePanelReportActions'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useIsInSidePanel from './useIsInSidePanel'; import useOnyx from './useOnyx'; @@ -47,6 +48,11 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { return false; } return Object.values(actions).some((action) => { + // The list pins a still-open task, so it stays on screen in every session. + if (isOpenChildTaskAction(action, currentUserAccountID)) { + return true; + } + // An action that opens its own report, like a question's thread or a task, keeps the welcome both while sending and once sent. // Otherwise reconnecting would switch this chat to a partial view that `Hide chat history` can't return to. if (action.actorAccountID === currentUserAccountID && !!action.childReportID) { diff --git a/src/hooks/useConciergeSidePanelReportActions.ts b/src/hooks/useConciergeSidePanelReportActions.ts index 7ca638fa6997..4d78e42fc0c4 100644 --- a/src/hooks/useConciergeSidePanelReportActions.ts +++ b/src/hooks/useConciergeSidePanelReportActions.ts @@ -269,3 +269,4 @@ function useConciergeSidePanelReportActions({ } export default useConciergeSidePanelReportActions; +export {isOpenChildTaskAction}; diff --git a/tests/unit/hooks/useConciergeAskState.test.tsx b/tests/unit/hooks/useConciergeAskState.test.tsx index 1b52ff2fe264..4951916ce5e2 100644 --- a/tests/unit/hooks/useConciergeAskState.test.tsx +++ b/tests/unit/hooks/useConciergeAskState.test.tsx @@ -135,22 +135,39 @@ describe('useConciergeAskState', () => { // Then the welcome stays, like it does for a question expect(result.current.shouldShowWelcome).toBe(true); + // When that task is assigned to the current user and still open + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, { + [question.reportActionID]: {childManagerAccountID: 100, childStateNum: CONST.REPORT.STATE_NUM.OPEN, childStatusNum: CONST.REPORT.STATUS_NUM.OPEN}, + }); + await waitForBatchedUpdates(); + + // Then the welcome stands down, so the open task stays pinned on screen + expect(result.current.shouldShowWelcome).toBe(false); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, null); await waitForBatchedUpdates(); }); it('shows the earlier conversation when the report opens at a linked action', () => { + // Given a route that opens the Concierge chat at a linked message mockRouteParams = {reportActionID: '2'}; + // When the Concierge chat renders const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); + // Then the earlier conversation shows, because the linked message sits inside it expect(result.current.isHistoryExpanded).toBe(true); expect(result.current.shouldShowWelcome).toBe(false); }); it('stays off outside the Concierge report', () => { - const {result} = renderHook(() => useConciergeAskState('999'), {wrapper}); + // Given a report that isn't the Concierge chat + const reportID = '999'; + + // When that report renders + const {result} = renderHook(() => useConciergeAskState(reportID), {wrapper}); + // Then none of the Ask Concierge screen applies to it expect(result.current.isAskConciergeChat).toBe(false); expect(result.current.shouldShowWelcome).toBe(false); }); From 675535b4dc61f5f37156baef2014084350ec4d85 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 16 Oct 2026 14:16:55 +0200 Subject: [PATCH 079/156] Hide the Ask a new question label while the composer is full size --- src/pages/inbox/report/ReportFooter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportFooter.tsx b/src/pages/inbox/report/ReportFooter.tsx index e4c776e38a51..25ac35c9c71e 100644 --- a/src/pages/inbox/report/ReportFooter.tsx +++ b/src/pages/inbox/report/ReportFooter.tsx @@ -142,7 +142,7 @@ function ReportFooter() { return ( {merchantRuleBanner} - {shouldLabelComposerAsNewQuestion && {translate('common.concierge.askNewQuestion')}} + {shouldLabelComposerAsNewQuestion && !isComposerFullSize && {translate('common.concierge.askNewQuestion')}} {shouldShowEnableNotificationsBanner ? ( <> From e24e81ca16c2ee2c37e038ff78fb2b5bda1c67e6 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 16 Oct 2026 14:16:57 +0200 Subject: [PATCH 080/156] Start a new Concierge thread from Search while the side panel shows an earlier one --- .../Search/SearchRouter/useAskConcierge.tsx | 6 +++++- tests/unit/hooks/useAskConcierge.test.tsx | 13 +++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/components/Search/SearchRouter/useAskConcierge.tsx b/src/components/Search/SearchRouter/useAskConcierge.tsx index 7179a22bdb6d..49f88813d359 100644 --- a/src/components/Search/SearchRouter/useAskConcierge.tsx +++ b/src/components/Search/SearchRouter/useAskConcierge.tsx @@ -26,7 +26,11 @@ function useAskConcierge({forceConcierge = false}: {forceConcierge?: boolean} = const sidePanelReportID = useSidePanelReportID(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const {openConciergeAnywhere, isInSidePanel} = useOpenConciergeAnywhere(); - const targetReportID = !forceConcierge && isInSidePanel && sidePanelReportID ? sidePanelReportID : conciergeReportID; + const [sidePanelReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(sidePanelReportID)}`); + + // Each new question gets its own Concierge thread, so a side panel showing an earlier thread still targets Concierge. + const isSidePanelOnConciergeThread = !!conciergeReportID && sidePanelReport?.parentReportID === conciergeReportID; + const targetReportID = !forceConcierge && isInSidePanel && sidePanelReportID && !isSidePanelOnConciergeThread ? sidePanelReportID : conciergeReportID; const [targetReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(targetReportID)}`); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {timezone, accountID: currentUserAccountID} = currentUserPersonalDetails; diff --git a/tests/unit/hooks/useAskConcierge.test.tsx b/tests/unit/hooks/useAskConcierge.test.tsx index 8a05a3b58b4a..3a91669b0968 100644 --- a/tests/unit/hooks/useAskConcierge.test.tsx +++ b/tests/unit/hooks/useAskConcierge.test.tsx @@ -1,4 +1,4 @@ -import {renderHook} from '@testing-library/react-native'; +import {renderHook, waitFor} from '@testing-library/react-native'; import useAskConcierge from '@components/Search/SearchRouter/useAskConcierge'; @@ -86,10 +86,19 @@ describe('useAskConcierge', () => { await seedReports(); // When the hook resolves its target - const {result} = renderAskConcierge(); + const {result, rerender} = renderAskConcierge(); // Then it targets that report, not Concierge expect(result.current.conciergeTargetReportID).toBe(ADMINS_ROOM_REPORT_ID); + + // When the side panel shows the thread of an earlier Concierge question + const conciergeThread = {reportID: '300', parentReportID: CONCIERGE_REPORT_ID} as Report; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${conciergeThread.reportID}`, conciergeThread); + mockSidePanelReportID = conciergeThread.reportID; + rerender({}); + + // Then it targets Concierge, so the next question opens a new thread + await waitFor(() => expect(result.current.conciergeTargetReportID).toBe(CONCIERGE_REPORT_ID)); }); it('targets Concierge when forced, even though the side panel maps elsewhere', async () => { From 20ab913854a008f092bc3562060042de9177ab33 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 16 Oct 2026 14:27:21 +0200 Subject: [PATCH 081/156] Return to the Concierge welcome when hiding history after sending several attachments --- src/hooks/useConciergeAskState.ts | 10 +++++----- tests/unit/hooks/useConciergeAskState.test.tsx | 13 ++++++++++--- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/hooks/useConciergeAskState.ts b/src/hooks/useConciergeAskState.ts index 0933d6b0430e..9980f8d50cba 100644 --- a/src/hooks/useConciergeAskState.ts +++ b/src/hooks/useConciergeAskState.ts @@ -7,6 +7,7 @@ import {useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type {ReportActions} from '@src/types/onyx/ReportAction'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxEntry} from 'react-native-onyx'; @@ -53,12 +54,11 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { return true; } - // An action that opens its own report, like a question's thread or a task, keeps the welcome both while sending and once sent. - // Otherwise reconnecting would switch this chat to a partial view that `Hide chat history` can't return to. - if (action.actorAccountID === currentUserAccountID && !!action.childReportID) { - return false; + // The user's own messages keep the welcome, so `Hide chat history` always returns to it. A failed one stays on screen to show its error. + if (action.actorAccountID === currentUserAccountID) { + return !isEmptyObject(action.errors) && (isCurrentUserPendingAddAction(action, currentUserAccountID) || action.created >= sessionStartTime); } - return isCurrentUserPendingAddAction(action, currentUserAccountID) || (!isCreatedAction(action) && action.created >= sessionStartTime); + return !isCreatedAction(action) && action.created >= sessionStartTime; }); }; const [hasSessionActivity] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { diff --git a/tests/unit/hooks/useConciergeAskState.test.tsx b/tests/unit/hooks/useConciergeAskState.test.tsx index 4951916ce5e2..9da06c4bf5fa 100644 --- a/tests/unit/hooks/useConciergeAskState.test.tsx +++ b/tests/unit/hooks/useConciergeAskState.test.tsx @@ -121,15 +121,22 @@ describe('useConciergeAskState', () => { // Then the welcome still stays, so the collapsed chat doesn't switch to a partial list on its own expect(result.current.shouldShowWelcome).toBe(true); - // When the thread fails to be created, which clears the question's child thread + // When the message has no thread, like each of several attachments sent together await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[question.reportActionID]: {childReportID: null, childType: ''}}); await waitForBatchedUpdates(); - // Then the question counts as activity, so its error stays visible in this chat + // Then the welcome still stays, so `Hide chat history` returns to it + expect(result.current.shouldShowWelcome).toBe(true); + + // When the message fails to send + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[question.reportActionID]: {errors: {1: 'Failed'}}}); + await waitForBatchedUpdates(); + + // Then the message counts as activity, so its error stays visible in this chat expect(result.current.shouldShowWelcome).toBe(false); // When the current user creates a task in the Concierge chat - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[question.reportActionID]: {childReportID: '5', childType: CONST.REPORT.TYPE.TASK}}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[question.reportActionID]: {childReportID: '5', childType: CONST.REPORT.TYPE.TASK, errors: null}}); await waitForBatchedUpdates(); // Then the welcome stays, like it does for a question From 9a8cf01498dd2828c3190b354378491385d93849 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 16 Oct 2026 15:24:38 +0200 Subject: [PATCH 082/156] Use a camelCase error key in the Concierge ask state test --- tests/unit/hooks/useConciergeAskState.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/hooks/useConciergeAskState.test.tsx b/tests/unit/hooks/useConciergeAskState.test.tsx index 9da06c4bf5fa..6af486e144a0 100644 --- a/tests/unit/hooks/useConciergeAskState.test.tsx +++ b/tests/unit/hooks/useConciergeAskState.test.tsx @@ -129,7 +129,7 @@ describe('useConciergeAskState', () => { expect(result.current.shouldShowWelcome).toBe(true); // When the message fails to send - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[question.reportActionID]: {errors: {1: 'Failed'}}}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[question.reportActionID]: {errors: {error1: 'Something went wrong'}}}); await waitForBatchedUpdates(); // Then the message counts as activity, so its error stays visible in this chat From 427f1aa2256264decc1399318e7f18db64224623 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 16 Oct 2026 13:50:45 +0200 Subject: [PATCH 083/156] Build optimistic comments with several attachments --- src/libs/API/parameters/AddCommentOrAttachmentParams.ts | 2 ++ src/libs/ReportUtils.ts | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/libs/API/parameters/AddCommentOrAttachmentParams.ts b/src/libs/API/parameters/AddCommentOrAttachmentParams.ts index 86ad7fdd9c59..d4dbf44fcaed 100644 --- a/src/libs/API/parameters/AddCommentOrAttachmentParams.ts +++ b/src/libs/API/parameters/AddCommentOrAttachmentParams.ts @@ -6,7 +6,9 @@ type AddCommentOrAttachmentParams = { commentReportActionID?: string | null; reportComment?: string; attachmentID?: string; + attachmentIDs?: string; file?: FileObject; + [additionalFileKey: `file_${number}`]: FileObject; timezone?: string; clientCreatedTime?: string; isOldDotConciergeChat?: boolean; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index d82adf918452..fcdc42c7ccca 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -364,6 +364,7 @@ type BuildOptimisticAddCommentReportActionParams = { reportID?: string; reportActionID?: string; attachmentID?: string; + additionalAttachments?: Array<{file: FileObject; attachmentID: string}>; isHTML?: boolean; delegateAccountIDParam: number | undefined; /** Anchor `created` to the server clock so it is comparable to Concierge's server-stamped replies. */ @@ -7627,6 +7628,7 @@ function buildOptimisticAddCommentReportAction({ reportID, reportActionID = rand64(), attachmentID, + additionalAttachments = [], currentUserEmail, currentUserAccountID, isHTML = false, @@ -7635,7 +7637,12 @@ function buildOptimisticAddCommentReportAction({ lastActionCreated, }: BuildOptimisticAddCommentReportActionParams): OptimisticReportAction { const commentText = isHTML ? (text ?? '') : getParsedComment(text ?? '', {reportID}); - const attachmentHtml = getUploadingAttachmentHtml(file, attachmentID); + const attachmentHtml = [ + getUploadingAttachmentHtml(file, attachmentID), + ...additionalAttachments.map((attachment) => getUploadingAttachmentHtml(attachment.file, attachment.attachmentID)), + ] + .filter((html) => !!html) + .join('
'); const htmlForNewComment = `${commentText}${commentText && attachmentHtml ? '

' : ''}${attachmentHtml}`; const textForNewComment = Parser.htmlToText(htmlForNewComment); From 1c872d5903c00a989605a1f48f0c72669d1f1bbc Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 16 Oct 2026 13:50:56 +0200 Subject: [PATCH 084/156] Send extra attachments in the same AddAttachment request --- src/libs/actions/Report/index.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index d80d9644bb46..54e26154e853 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -428,6 +428,7 @@ type AddActionsParams = { currentUserAccountID: number; text?: string; file?: FileObject; + additionalFiles?: FileObject[]; isInSidePanel?: boolean; sidePanelContext?: SidePanelContext; pregeneratedResponseParams?: PregeneratedResponseParams; @@ -872,6 +873,7 @@ function addActions({ currentUserAccountID, text = '', file, + additionalFiles = [], isInSidePanel = false, sidePanelContext, pregeneratedResponseParams, @@ -907,6 +909,7 @@ function addActions({ let commandName: typeof WRITE_COMMANDS.ADD_COMMENT | typeof WRITE_COMMANDS.ADD_ATTACHMENT | typeof WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT = WRITE_COMMANDS.ADD_COMMENT; const attachmentID = rand64(); + const additionalAttachments = additionalFiles.map((additionalFile) => ({file: additionalFile, attachmentID: rand64()})); if (text && !file) { const reportComment = buildOptimisticAddCommentReportAction({ text, @@ -929,12 +932,16 @@ function addActions({ file, reportID, attachmentID, + additionalAttachments, delegateAccountIDParam: delegateAccountID, anchorCreatedToServer: isConciergeChat, lastActionCreated, }); attachmentAction = attachment.reportAction; cacheAttachment({attachmentID, uri: file.uri ?? '', mimeType: file.type}); + for (const additionalAttachment of additionalAttachments) { + cacheAttachment({attachmentID: additionalAttachment.attachmentID, uri: additionalAttachment.file.uri ?? '', mimeType: additionalAttachment.file.type}); + } } if (text && file) { @@ -1031,6 +1038,13 @@ function addActions({ parameters.attachmentID = attachmentID; } + if (file && additionalAttachments.length > 0) { + for (const [index, additionalAttachment] of additionalAttachments.entries()) { + parameters[`file_${index + 1}`] = additionalAttachment.file; + } + parameters.attachmentIDs = [attachmentID, ...additionalAttachments.map((additionalAttachment) => additionalAttachment.attachmentID)].join(','); + } + if (isInSidePanel && (isConciergeChat || isAdminRoom(reportForAction))) { const pageHTML = capturePageHTML(); if (pageHTML) { From 9c3556f085494f8b625c1a34c161a9ea18b824fc Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 16 Oct 2026 13:51:31 +0200 Subject: [PATCH 085/156] Open a Concierge thread for messages with several attachments --- .../Search/SearchRouter/useAskConcierge.tsx | 3 +-- src/libs/actions/Report/index.ts | 12 +++++------- .../report/ReportActionCompose/useComposerSubmit.ts | 3 +-- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/components/Search/SearchRouter/useAskConcierge.tsx b/src/components/Search/SearchRouter/useAskConcierge.tsx index 49f88813d359..75a9b608a4f4 100644 --- a/src/components/Search/SearchRouter/useAskConcierge.tsx +++ b/src/components/Search/SearchRouter/useAskConcierge.tsx @@ -75,8 +75,7 @@ function useAskConcierge({forceConcierge = false}: {forceConcierge?: boolean} = return; } - const willOpenThread = shouldRespondInThread && (!Array.isArray(attachments) || attachments.length === 1); - const conciergeThreadReportID = willOpenThread ? generateReportID() : undefined; + const conciergeThreadReportID = shouldRespondInThread ? generateReportID() : undefined; addAttachmentWithComment({ report: targetReport, notifyReportID: targetReportID, diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 54e26154e853..2cce781ba02d 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -1321,8 +1321,9 @@ function addAttachmentWithComment({ playSound(SOUNDS.DONE); }; - // Single attachment - if (!Array.isArray(attachments)) { + // Single attachment, or several in one message so Concierge answers them in one thread + if (!Array.isArray(attachments) || conciergeThreadReportID) { + const [file, ...additionalFiles] = Array.isArray(attachments) ? attachments : [attachments]; addActions({ report, notifyReportID, @@ -1330,7 +1331,8 @@ function addAttachmentWithComment({ timezoneParam: timezone, currentUserAccountID, text, - file: attachments, + file, + additionalFiles, isInSidePanel, delegateAccountID, sidePanelContext, @@ -1354,10 +1356,6 @@ function addAttachmentWithComment({ isInSidePanel, delegateAccountID, conciergeReportID, - - // Several attachments post one message each, so they stay in the DM instead of opening a thread. - conciergeThreadReportID: attachments.length === 1 ? conciergeThreadReportID : undefined, - shouldNavigateToConciergeThread, }); // Remaining: attachment-only actions (no text duplication) diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts index 1cb61d69feb7..79427f0bf29a 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts @@ -84,8 +84,7 @@ function useComposerSubmit(reportID: string) { if (attachmentFileRef.current) { const attachments = attachmentFileRef.current; - const willOpenThread = shouldRespondInThread && (!Array.isArray(attachments) || attachments.length === 1); - const conciergeThreadReportID = willOpenThread ? generateReportID() : undefined; + const conciergeThreadReportID = shouldRespondInThread ? generateReportID() : undefined; addAttachmentWithComment({ report: targetReport, notifyReportID: reportID, From 70bc106875af6a0ab7b41c9b9449cfd088601f54 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 16 Oct 2026 13:51:36 +0200 Subject: [PATCH 086/156] Re-read every attachment file for requests queued offline --- src/libs/prepareRequestPayload/index.native.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/prepareRequestPayload/index.native.ts b/src/libs/prepareRequestPayload/index.native.ts index 4334d3794187..afca500563d2 100644 --- a/src/libs/prepareRequestPayload/index.native.ts +++ b/src/libs/prepareRequestPayload/index.native.ts @@ -84,7 +84,7 @@ const prepareRequestPayload: PrepareRequestPayload = (command, data, initiatedOf } } - if (key === 'file' && initiatedOffline) { + if ((key === 'file' || key.startsWith('file_')) && initiatedOffline) { const {uri: path = '', source, name, type} = value as File; if (!source) { validateFormDataParameter(command, key, value); From 623ee0055782846ba4e7c9a19ed6b89edb82dacb Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 16 Oct 2026 14:02:35 +0200 Subject: [PATCH 087/156] Test that several attachments open one Concierge thread --- tests/actions/ReportTest.ts | 43 +++++++++++++++++++++++ tests/unit/hooks/useAskConcierge.test.tsx | 9 ++--- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 836fcee06429..05a712d90bcc 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -44,6 +44,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; +import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; @@ -2272,6 +2273,48 @@ describe('actions/Report', () => { expect(relevant.slice(1).every((r) => r.command === WRITE_COMMANDS.ADD_ATTACHMENT)).toBe(true); }); + it('should post several attachments as one action when Concierge answers in a thread', async () => { + setHasRadio(false); + await waitForBatchedUpdates(); + + // Given two files sent with a caption to Concierge, which answers each question in a thread + const REPORT: OnyxTypes.Report = createRandomReport(1, undefined); + const fileA: FileObject = {name: 'a.jpg', type: 'image/jpeg', uri: 'file://a.jpg'}; + const fileB: FileObject = {name: 'b.jpg', type: 'image/jpeg', uri: 'file://b.jpg'}; + const conciergeThreadReportID = '9001'; + + // When they are sent together + Report.addAttachmentWithComment({ + report: REPORT, + notifyReportID: REPORT.reportID, + ancestors: [], + attachments: [fileA, fileB], + currentUserAccountID: 1, + text: 'Hello world', + delegateAccountID: undefined, + conciergeReportID: undefined, + conciergeThreadReportID, + shouldNavigateToConciergeThread: false, + }); + await waitForBatchedUpdates(); + + // Then one request carries both files with an attachment ID for each, so Concierge reads them together + const requests = PersistedRequests.getAll().filter((request) => request.command === WRITE_COMMANDS.ADD_ATTACHMENT || request.command === WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT); + expect(requests).toHaveLength(1); + const data = requests.at(0)?.data; + expect(data?.file).toEqual(fileA); + expect(data?.file_1).toEqual(fileB); + expect(String(data?.attachmentIDs).split(',')).toEqual([String(data?.attachmentID), expect.any(String)]); + + // And one report action shows both files and opens the thread + const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT.reportID}` as const); + const reportAction = reportActions?.[String(data?.reportActionID)]; + const message = Array.isArray(reportAction?.message) ? reportAction?.message.at(0) : undefined; + expect(message?.html).toContain('file://a.jpg'); + expect(message?.html).toContain('file://b.jpg'); + expect(reportAction?.childReportID).toBe(conciergeThreadReportID); + }); + it('should create attachment only actions when adding multiple attachments without a comment', async () => { global.fetch = TestHelper.createGlobalFetchMock({ headers: new Headers({ diff --git a/tests/unit/hooks/useAskConcierge.test.tsx b/tests/unit/hooks/useAskConcierge.test.tsx index 3a91669b0968..d146dca62a1c 100644 --- a/tests/unit/hooks/useAskConcierge.test.tsx +++ b/tests/unit/hooks/useAskConcierge.test.tsx @@ -320,7 +320,7 @@ describe('useAskConcierge', () => { expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true, reportID: conciergeThreadReportID}); }); - it('keeps several attachments in the Concierge chat', async () => { + it('shows the thread in the side panel for several attachments', async () => { // Given web, where Concierge answers each question in its own thread mockIsInSidePanel = true; await seedReports(); @@ -330,9 +330,10 @@ describe('useAskConcierge', () => { // When two attachments are sent at once result.current.askConciergeWithAttachment([...FILES, {name: 'second.jpg', type: 'image/jpeg', uri: 'file://second.jpg'}], 'Here they are'); - // Then no thread is created and the side panel stays on the Concierge chat - expect(mockAddAttachmentWithComment).toHaveBeenCalledWith(expect.objectContaining({conciergeThreadReportID: undefined})); - expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true, reportID: undefined}); + // Then the side panel shows the thread they were posted to + const conciergeThreadReportID = mockAddAttachmentWithComment.mock.calls.at(0)?.at(0)?.conciergeThreadReportID; + expect(conciergeThreadReportID).toEqual(expect.any(String)); + expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true, reportID: conciergeThreadReportID}); }); }); }); From a84b7bc9ad257af4726456de114462a4e68fc7d6 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 16 Oct 2026 14:47:12 +0200 Subject: [PATCH 088/156] Reconcile every queued attachment when editing a message --- src/libs/ReportUtils.ts | 20 +++++---- src/libs/actions/Report/index.ts | 8 ++-- src/libs/actions/RequestConflictUtils.ts | 35 +++++++++++++--- tests/unit/ReportUtilsTest.ts | 40 +++++++++++++++--- tests/unit/RequestConflictUtilsTest.ts | 52 +++++++++++++++++++++++- 5 files changed, 130 insertions(+), 25 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index fcdc42c7ccca..e3e3902c68cd 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -7576,8 +7576,8 @@ function buildEditedCommentWithAttachment(commentHtml: string, uploadingAttachme return [commentHtml, uploadingAttachmentHtml].filter(Boolean).join('

'); } -/** The still-uploading attachment tag, re-appended to an edit's optimistic message so it stays on screen. */ -function getUploadingAttachmentHtmlFromComment(currentCommentHtml: string | undefined): string | undefined { +/** The still-uploading attachment tags, re-appended to an edit's optimistic message so they stay on screen. */ +function getUploadingAttachmentHtmlFromComment(currentCommentHtml: string | undefined, removedSources: string[] = []): string | undefined { if (!currentCommentHtml?.includes(CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE)) { return undefined; } @@ -7585,18 +7585,20 @@ function getUploadingAttachmentHtmlFromComment(currentCommentHtml: string | unde `]*${CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE}="[^"]*"[^>]*>` + `|]*${CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE}="[^"]*"[^>]*>[\\s\\S]*?` + `|]*${CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE}="[^"]*"[^>]*>[\\s\\S]*?`, - 'i', + 'gi', ); - return currentCommentHtml.match(attachmentTagRegex)?.at(0); + const optimisticSourceRegex = new RegExp(`${CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE}="([^"]+)"`); + const keptTags = (currentCommentHtml.match(attachmentTagRegex) ?? []).filter((tag) => !removedSources.includes(tag.match(optimisticSourceRegex)?.at(1) ?? '')); + return keptTags.length > 0 ? keptTags.join('
') : undefined; } /** - * Whether a draft dropped a still-uploading attachment. Compared against the local URI, not the parsed HTML, + * The local URIs of still-uploading attachments a draft dropped. Compared against the local URI, not the parsed HTML, * because a kept reference stays plain markdown and never parses back into an attachment tag. */ -function isUploadingAttachmentRemovedFromDraft(draftMarkdown: string, currentCommentHtml: string | undefined): boolean { - const localSource = currentCommentHtml?.match(new RegExp(`${CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE}="([^"]+)"`))?.at(1); - return !!localSource && !draftMarkdown.includes(localSource); +function getUploadingAttachmentSourcesRemovedFromDraft(draftMarkdown: string, currentCommentHtml: string | undefined): string[] { + const localSources = [...(currentCommentHtml ?? '').matchAll(new RegExp(`${CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE}="([^"]+)"`, 'g'))].map((match) => match[1]); + return localSources.filter((localSource) => !draftMarkdown.includes(localSource)); } function getReportDescription(report: OnyxEntry): string { @@ -15644,7 +15646,7 @@ export { canMergeReports, canModifyHoldStatus, replaceLocalAttachmentReferences, - isUploadingAttachmentRemovedFromDraft, + getUploadingAttachmentSourcesRemovedFromDraft, getUploadingAttachmentHtmlFromComment, buildEditedCommentWithAttachment, parseMovedTransactionReportIDs, diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 2cce781ba02d..a786adb37a75 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -176,7 +176,7 @@ import { isReportManuallyReimbursed, isReportNotFound, isSelfDM, - isUploadingAttachmentRemovedFromDraft, + getUploadingAttachmentSourcesRemovedFromDraft, isValidReportIDFromPath, prepareOnboardingOnyxData, replaceLocalAttachmentReferences, @@ -3731,7 +3731,7 @@ function editReportComment( // https://github.com/Expensify/App/issues/13221 const originalCommentHTML = ReportActionsUtils.getReportActionHtml(originalReportAction); const originalCommentMarkdown = Parser.htmlToMarkdown(originalCommentHTML ?? '', {reportIDToName}).trim(); - const shouldRemoveQueuedAttachment = isUploadingAttachmentRemovedFromDraft(textForNewComment, originalCommentHTML); + const removedAttachmentSources = getUploadingAttachmentSourcesRemovedFromDraft(textForNewComment, originalCommentHTML); const draftForNewComment = replaceLocalAttachmentReferences(textForNewComment, originalCommentHTML, originalReportAction.reportActionID); // Skip the Edit if draft is not changed @@ -3760,7 +3760,7 @@ function editReportComment( const originalMessage = ReportActionsUtils.getReportActionMessage(originalReportAction); // Optimistic message only: the sent copy is stripped, so without this the attachment vanishes until upload lands. - const uploadingAttachmentHtml = shouldRemoveQueuedAttachment ? undefined : getUploadingAttachmentHtmlFromComment(originalCommentHTML); + const uploadingAttachmentHtml = getUploadingAttachmentHtmlFromComment(originalCommentHTML, removedAttachmentSources); const optimisticHtml = buildEditedCommentWithAttachment(htmlForNewComment, uploadingAttachmentHtml); const optimisticText = uploadingAttachmentHtml ? Parser.htmlToText(optimisticHtml) : reportComment; @@ -3863,7 +3863,7 @@ function editReportComment( checkAndFixConflictingRequest: (persistedRequests) => { const addCommentIndex = persistedRequests.findIndex((request) => addNewMessageWithText.has(request.command) && request.data?.reportActionID === reportActionID); if (addCommentIndex > -1) { - return resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, addCommentIndex, shouldRemoveQueuedAttachment); + return resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, addCommentIndex, removedAttachmentSources); } return resolveDuplicationConflictAction(persistedRequests as AnyRequest[], createUpdateCommentMatcher(reportActionID)); }, diff --git a/src/libs/actions/RequestConflictUtils.ts b/src/libs/actions/RequestConflictUtils.ts index 4f6926ef1db2..18b06b4e8698 100644 --- a/src/libs/actions/RequestConflictUtils.ts +++ b/src/libs/actions/RequestConflictUtils.ts @@ -5,6 +5,7 @@ import type {ApiRequestCommandParameters} from '@libs/API/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type OnyxRequest from '@src/types/onyx/Request'; import type {AnyRequest, ConflictActionData} from '@src/types/onyx/Request'; +import type {FileObject} from '@src/types/utils/Attachment'; import type {OnyxKey, OnyxUpdate} from 'react-native-onyx'; import type {TupleToUnion} from 'type-fest'; @@ -280,7 +281,7 @@ function resolveEditCommentWithNewAddCommentRequest( parameters: UpdateCommentParams, reportActionID: string, addCommentIndex: number, - shouldRemoveQueuedAttachment = false, + removedAttachmentSources: string[] = [], ): ConflictActionData { const indicesToDelete: number[] = []; for (const [index, request] of persistedRequests.entries()) { @@ -295,11 +296,33 @@ function resolveEditCommentWithNewAddCommentRequest( if (currentAddComment) { currentAddComment.data = {...currentAddComment.data, ...parameters}; - // The queued request keeps its own file, so without dropping it the server would re-add an attachment the edit removed. - if (shouldRemoveQueuedAttachment) { - delete currentAddComment.data.file; - delete currentAddComment.data.attachmentID; - currentAddComment.command = WRITE_COMMANDS.ADD_COMMENT; + // The queued request keeps its own files, so without dropping them the server would re-add attachments the edit removed. + if (removedAttachmentSources.length > 0) { + const data = currentAddComment.data; + const fileKeys = ['file', ...Object.keys(data).filter((key) => /^file_\d+$/.test(key))]; + const attachmentIDs = typeof data.attachmentIDs === 'string' ? data.attachmentIDs.split(',') : [data.attachmentID]; + const keptAttachments = fileKeys + .map((key, index) => ({file: data[key] as FileObject | undefined, attachmentID: attachmentIDs.at(index)})) + .filter(({file}) => !!file && !removedAttachmentSources.includes(file.uri ?? '')); + + for (const key of fileKeys) { + delete data[key]; + } + delete data.attachmentID; + delete data.attachmentIDs; + + for (const [index, keptAttachment] of keptAttachments.entries()) { + data[index === 0 ? 'file' : `file_${index}`] = keptAttachment.file; + } + if (keptAttachments.length > 0) { + data.attachmentID = keptAttachments.at(0)?.attachmentID; + } + if (keptAttachments.length > 1) { + data.attachmentIDs = keptAttachments.map((keptAttachment) => keptAttachment.attachmentID).join(','); + } + if (keptAttachments.length === 0) { + currentAddComment.command = WRITE_COMMANDS.ADD_COMMENT; + } } nextAction = { diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index c0db774d66ff..dcd7a3cd0096 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -205,7 +205,7 @@ import { isSortableColumnName, isThreadMemberProtectedByParentReport, isUnread, - isUploadingAttachmentRemovedFromDraft, + getUploadingAttachmentSourcesRemovedFromDraft, isWorkspaceMemberLeavingWorkspaceRoom, parseMovedTransactionReportIDs, parseReportRouteParams, @@ -7727,17 +7727,17 @@ describe('ReportUtils', () => { }); it('reports the uploading attachment as removed once the draft drops its local reference', () => { - expect(isUploadingAttachmentRemovedFromDraft('Hello edited', uploadingImageHtml)).toBe(true); + expect(getUploadingAttachmentSourcesRemovedFromDraft('Hello edited', uploadingImageHtml)).toEqual(['blob:https://dev.new.expensify.com:8082/uuid-1']); }); it('does not report a removal while the draft still references the uploading attachment', () => { const draft = 'Hello edited\n\n!(blob:https://dev.new.expensify.com:8082/uuid-1)'; - expect(isUploadingAttachmentRemovedFromDraft(draft, uploadingImageHtml)).toBe(false); + expect(getUploadingAttachmentSourcesRemovedFromDraft(draft, uploadingImageHtml)).toEqual([]); }); it('does not report a removal when the attachment already synced', () => { - expect(isUploadingAttachmentRemovedFromDraft('Hello edited', syncedImageHtml)).toBe(false); + expect(getUploadingAttachmentSourcesRemovedFromDraft('Hello edited', syncedImageHtml)).toEqual([]); }); it('returns the uploading attachment tag so the optimistic message can keep showing it', () => { @@ -7747,6 +7747,36 @@ describe('ReportUtils', () => { expect(tag?.startsWith(' { + const firstSource = 'blob:https://dev.new.expensify.com:8082/uuid-1'; + const secondSource = 'blob:https://dev.new.expensify.com:8082/uuid-2'; + const uploadingImagesHtml = `Hello


`; + + it('reports only the attachments the draft dropped', () => { + // Given a draft that keeps the second image but not the first + const draft = `Hello edited\n\n!(${secondSource})`; + + // Then only the first image counts as removed + expect(getUploadingAttachmentSourcesRemovedFromDraft(draft, uploadingImagesHtml)).toEqual([firstSource]); + }); + + it('keeps showing every attachment the edit kept', () => { + // When the first image is removed + const html = getUploadingAttachmentHtmlFromComment(uploadingImagesHtml, [firstSource]); + + // Then only the second image is re-appended to the edited message + expect(html).toContain(secondSource); + expect(html).not.toContain(firstSource); + }); + + it('re-appends all the attachments when none were removed', () => { + const html = getUploadingAttachmentHtmlFromComment(uploadingImagesHtml); + + expect(html).toContain(firstSource); + expect(html).toContain(secondSource); + }); + }); + it('returns nothing to re-append once the attachment has synced', () => { expect(getUploadingAttachmentHtmlFromComment(syncedImageHtml)).toBeUndefined(); }); @@ -7783,7 +7813,7 @@ describe('ReportUtils', () => { const secondEditDraft = Parser.htmlToMarkdown(storedAfterFirstEdit).trim(); expect(secondEditDraft).toContain(videoSource); - expect(isUploadingAttachmentRemovedFromDraft(secondEditDraft, storedAfterFirstEdit)).toBe(false); + expect(getUploadingAttachmentSourcesRemovedFromDraft(secondEditDraft, storedAfterFirstEdit)).toEqual([]); }); }); diff --git a/tests/unit/RequestConflictUtilsTest.ts b/tests/unit/RequestConflictUtilsTest.ts index bd1c154e091f..cca3e61bdb32 100644 --- a/tests/unit/RequestConflictUtilsTest.ts +++ b/tests/unit/RequestConflictUtilsTest.ts @@ -152,7 +152,7 @@ describe('RequestConflictUtils', () => { const reportActionID = '2'; const persistedRequests = [{command: 'AddTextAndAttachment', data: {reportActionID, reportComment: 'test', file: {uri: 'blob:local'}, attachmentID: '5'}}, {command: 'OpenReport'}]; const parameters = {reportID: '1', reportActionID, reportComment: 'attachment removed'}; - const result = resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, true); + const result = resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, ['blob:local']); expect(result).toEqual({ conflictAction: { type: 'replace', @@ -162,6 +162,56 @@ describe('RequestConflictUtils', () => { }); }); + it('resolveEditCommentWithNewAddCommentRequest should drop only the queued attachments the edit removed', () => { + // Given a queued message with three files and an attachment ID for each + const reportActionID = '2'; + const persistedRequests = [ + { + command: 'AddTextAndAttachment', + data: {reportActionID, reportComment: 'test', file: {uri: 'blob:a'}, file_1: {uri: 'blob:b'}, file_2: {uri: 'blob:c'}, attachmentID: '5', attachmentIDs: '5,6,7'}, + }, + ]; + const parameters = {reportID: '1', reportActionID, reportComment: 'b removed'}; + + // When the edit removes the middle file + const result = resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, ['blob:b']); + + // Then the request still sends the other two files, renumbered with their own IDs + expect(result.conflictAction).toEqual({ + type: 'replace', + index: 0, + request: { + command: 'AddTextAndAttachment', + data: {reportID: '1', reportActionID, reportComment: 'b removed', file: {uri: 'blob:a'}, file_1: {uri: 'blob:c'}, attachmentID: '5', attachmentIDs: '5,7'}, + }, + }); + }); + + it('resolveEditCommentWithNewAddCommentRequest should move the remaining queued attachment into the first slot', () => { + // Given a queued message with three files + const reportActionID = '2'; + const persistedRequests = [ + { + command: 'AddTextAndAttachment', + data: {reportActionID, reportComment: 'test', file: {uri: 'blob:a'}, file_1: {uri: 'blob:b'}, file_2: {uri: 'blob:c'}, attachmentID: '5', attachmentIDs: '5,6,7'}, + }, + ]; + const parameters = {reportID: '1', reportActionID, reportComment: 'only b left'}; + + // When the edit removes the first and last files + const result = resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, ['blob:a', 'blob:c']); + + // Then the request sends the one file left as a single attachment + expect(result.conflictAction).toEqual({ + type: 'replace', + index: 0, + request: { + command: 'AddTextAndAttachment', + data: {reportID: '1', reportActionID, reportComment: 'only b left', file: {uri: 'blob:b'}, attachmentID: '6'}, + }, + }); + }); + it('resolveEditCommentWithNewAddCommentRequest should keep the queued attachment when the edit kept it', () => { const reportActionID = '2'; const persistedRequests = [{command: 'AddTextAndAttachment', data: {reportActionID, reportComment: 'test', file: {uri: 'blob:local'}, attachmentID: '5'}}, {command: 'OpenReport'}]; From ca486dbcd00dee62feabdee8cb4615e9f77321ec Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 16 Oct 2026 15:11:06 +0200 Subject: [PATCH 089/156] Space out file attachments in a message --- .../HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx index df3252f11630..562af783cf21 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx @@ -133,6 +133,7 @@ function AnchorRenderer({tnode, style, key}: AnchorRendererProps) { if (isAttachment && !isVideo) { return ( Date: Fri, 16 Oct 2026 22:04:18 +0200 Subject: [PATCH 090/156] Drop the chat history toggle for attachments that stay in the Concierge chat --- .../inbox/report/ReportActionCompose/useComposerSubmit.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts index 79427f0bf29a..a26e3b2a937f 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts @@ -104,9 +104,6 @@ function useComposerSubmit(reportID: string) { if (conciergeThreadReportID && isInSidePanel) { openSidePanel({reportID: conciergeThreadReportID}); } - if (shouldRespondInThread && !willOpenThread && !isInSidePanel) { - setShowFullHistory(true); - } attachmentFileRef.current = null; return; } From bcd753a7c0788fc9f660b00fe5b08dfc5069f5bf Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 16 Oct 2026 22:04:19 +0200 Subject: [PATCH 091/156] Revert "Use a camelCase error key in the Concierge ask state test" This reverts commit 9a8cf01498dd2828c3190b354378491385d93849. --- tests/unit/hooks/useConciergeAskState.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/hooks/useConciergeAskState.test.tsx b/tests/unit/hooks/useConciergeAskState.test.tsx index 6af486e144a0..9da06c4bf5fa 100644 --- a/tests/unit/hooks/useConciergeAskState.test.tsx +++ b/tests/unit/hooks/useConciergeAskState.test.tsx @@ -129,7 +129,7 @@ describe('useConciergeAskState', () => { expect(result.current.shouldShowWelcome).toBe(true); // When the message fails to send - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[question.reportActionID]: {errors: {error1: 'Something went wrong'}}}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[question.reportActionID]: {errors: {1: 'Failed'}}}); await waitForBatchedUpdates(); // Then the message counts as activity, so its error stays visible in this chat From 1053614a32f7031c37aa4c9a95e032bbd31b761b Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 16 Oct 2026 22:04:20 +0200 Subject: [PATCH 092/156] Revert "Return to the Concierge welcome when hiding history after sending several attachments" This reverts commit 20ab913854a008f092bc3562060042de9177ab33. --- src/hooks/useConciergeAskState.ts | 10 +++++----- tests/unit/hooks/useConciergeAskState.test.tsx | 13 +++---------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/hooks/useConciergeAskState.ts b/src/hooks/useConciergeAskState.ts index 9980f8d50cba..0933d6b0430e 100644 --- a/src/hooks/useConciergeAskState.ts +++ b/src/hooks/useConciergeAskState.ts @@ -7,7 +7,6 @@ import {useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type {ReportActions} from '@src/types/onyx/ReportAction'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxEntry} from 'react-native-onyx'; @@ -54,11 +53,12 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { return true; } - // The user's own messages keep the welcome, so `Hide chat history` always returns to it. A failed one stays on screen to show its error. - if (action.actorAccountID === currentUserAccountID) { - return !isEmptyObject(action.errors) && (isCurrentUserPendingAddAction(action, currentUserAccountID) || action.created >= sessionStartTime); + // An action that opens its own report, like a question's thread or a task, keeps the welcome both while sending and once sent. + // Otherwise reconnecting would switch this chat to a partial view that `Hide chat history` can't return to. + if (action.actorAccountID === currentUserAccountID && !!action.childReportID) { + return false; } - return !isCreatedAction(action) && action.created >= sessionStartTime; + return isCurrentUserPendingAddAction(action, currentUserAccountID) || (!isCreatedAction(action) && action.created >= sessionStartTime); }); }; const [hasSessionActivity] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { diff --git a/tests/unit/hooks/useConciergeAskState.test.tsx b/tests/unit/hooks/useConciergeAskState.test.tsx index 9da06c4bf5fa..4951916ce5e2 100644 --- a/tests/unit/hooks/useConciergeAskState.test.tsx +++ b/tests/unit/hooks/useConciergeAskState.test.tsx @@ -121,22 +121,15 @@ describe('useConciergeAskState', () => { // Then the welcome still stays, so the collapsed chat doesn't switch to a partial list on its own expect(result.current.shouldShowWelcome).toBe(true); - // When the message has no thread, like each of several attachments sent together + // When the thread fails to be created, which clears the question's child thread await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[question.reportActionID]: {childReportID: null, childType: ''}}); await waitForBatchedUpdates(); - // Then the welcome still stays, so `Hide chat history` returns to it - expect(result.current.shouldShowWelcome).toBe(true); - - // When the message fails to send - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[question.reportActionID]: {errors: {1: 'Failed'}}}); - await waitForBatchedUpdates(); - - // Then the message counts as activity, so its error stays visible in this chat + // Then the question counts as activity, so its error stays visible in this chat expect(result.current.shouldShowWelcome).toBe(false); // When the current user creates a task in the Concierge chat - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[question.reportActionID]: {childReportID: '5', childType: CONST.REPORT.TYPE.TASK, errors: null}}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[question.reportActionID]: {childReportID: '5', childType: CONST.REPORT.TYPE.TASK}}); await waitForBatchedUpdates(); // Then the welcome stays, like it does for a question From 727b7bcdd37dc5fe6335d16e70416e6233722626 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 16 Oct 2026 22:09:10 +0200 Subject: [PATCH 093/156] Read the Concierge history flag straight from the session in the list model --- src/hooks/useReportActionsListModel.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index 71928c8fb082..6de80a82b8de 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -11,7 +11,6 @@ import {reportActionsListLoadingStateSelector} from '@src/selectors/ReportMetaDa import {useRoute} from '@react-navigation/native'; -import useConciergeAskState from './useConciergeAskState'; import {useIsAppLoadPending} from './useInFlightRequests'; import useLoadReportActions from './useLoadReportActions'; import useNetworkWithOfflineStatus from './useNetworkWithOfflineStatus'; @@ -61,8 +60,7 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const isLoadingOlderReportActions = reportLoadingState?.isLoadingOlderReportActions; const hasLoadingOlderReportActionsError = reportLoadingState?.hasLoadingOlderReportActionsError; - const {sessionStartTime, hadMessagesAtSessionStart: conciergeHadMessagesAtSessionStart} = useConciergeSessionState(); - const {isHistoryExpanded} = useConciergeAskState(reportID); + const {sessionStartTime, showFullHistory: conciergeShowFullHistory, hadMessagesAtSessionStart: conciergeHadMessagesAtSessionStart} = useConciergeSessionState(); const {setShowFullHistory: setConciergeShowFullHistory, setHadMessagesAtSessionStart: setConciergeHadMessagesAtSessionStart} = useConciergeSessionActions(); const isReportTransactionThread = isReportTransactionThreadUtil(report); const shouldBeAlignedToTop = shouldReportAlignToTop(report, parentReportAction); @@ -102,7 +100,7 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea hasOlderActions, loadOlderChats, mainDMSessionStartTime: sessionStartTime, - conciergeShowFullHistory: isHistoryExpanded || !!reportActionIDFromRoute, + conciergeShowFullHistory: conciergeShowFullHistory || !!reportActionIDFromRoute, setConciergeShowFullHistory, conciergeHadMessagesAtSessionStart, setConciergeHadMessagesAtSessionStart, From 4f220af59fc20b50361c320d3476c91e5408afd3 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 16 Oct 2026 22:09:22 +0200 Subject: [PATCH 094/156] Fold the side panel check into the Concierge check --- src/pages/inbox/report/ShowPreviousMessagesButton.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/pages/inbox/report/ShowPreviousMessagesButton.tsx b/src/pages/inbox/report/ShowPreviousMessagesButton.tsx index 263cd79fcdec..7f293cc77035 100644 --- a/src/pages/inbox/report/ShowPreviousMessagesButton.tsx +++ b/src/pages/inbox/report/ShowPreviousMessagesButton.tsx @@ -35,11 +35,8 @@ function ShowPreviousMessagesButton({reportID, actionType, hasPreviousMessages, const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const isConciergeChat = reportID === conciergeReportID; - if (!isConciergeChat) { - return null; - } - // ConciergeChatHistoryToggle takes over in the Ask Concierge design, where the control also collapses the history again. - if (!isInSidePanel) { + // The main Concierge chat uses ConciergeChatHistoryToggle instead. + if (!isConciergeChat || !isInSidePanel) { return null; } if (!onPress) { From de7c33d21d3286bde06c5ae719bb8143a449d09c Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 16 Oct 2026 22:09:23 +0200 Subject: [PATCH 095/156] Drop the isAskConciergeEnabled alias in the header --- src/pages/inbox/HeaderView.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/pages/inbox/HeaderView.tsx b/src/pages/inbox/HeaderView.tsx index 45fe61104991..15602b4a0ee3 100644 --- a/src/pages/inbox/HeaderView.tsx +++ b/src/pages/inbox/HeaderView.tsx @@ -160,7 +160,6 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const isGroupChat = isGroupChatReportUtils(report) || isDeprecatedGroupDM(report, isReportArchived); const isConciergeChat = isConciergeChatReport(report, conciergeReportID); const {resetSession: resetConciergeSession} = useConciergeSessionActions(); - const isAskConciergeEnabled = !isInSidePanel; const isConciergeThread = !!conciergeReportID && report?.parentReportID === conciergeReportID; const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [onboarding] = useOnyx(ONYXKEYS.NVP_ONBOARDING); @@ -192,7 +191,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) // Use sorted display names for the title for group chats on native small screen widths const title = getReportName(reportHeaderData, derivedReportHeaderName); const chatRoomSubtitle = getChatRoomSubtitle(reportHeaderData, reportHeaderDataPolicy, conciergeReportID, translate, rules, false, isReportHeaderDataArchived); - const subtitle = isAskConciergeEnabled && isConciergeChat ? translate('common.concierge.subtitle') : chatRoomSubtitle; + const subtitle = !isInSidePanel && isConciergeChat ? translate('common.concierge.subtitle') : chatRoomSubtitle; // This is used to get the status badge for invoice report subtitle. const statusTextForInvoiceReport = isParentInvoiceAndIsTransactionThread ? getReportStatusTranslation({stateNum: reportHeaderData?.stateNum, statusNum: reportHeaderData?.statusNum, translate}) @@ -517,7 +516,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) )} - {isAskConciergeEnabled && isConciergeThread && newConciergeChatButton} + {!isInSidePanel && isConciergeThread && newConciergeChatButton} {shouldShowBookCall && !shouldStackBookCall && bookCallButton} {shouldShowOnBoardingHelpDropdownButton && !shouldUseNarrowLayout && onboardingHelpDropdownButton} {!shouldUseNarrowLayout && !shouldShowDiscount && isChatUsedForOnboarding && ( From 64f75c7068b40f0f67b712034ae71dce46776036 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 16 Oct 2026 22:36:33 +0200 Subject: [PATCH 096/156] Match extra attachment file keys with one shared regex --- src/CONST/index.ts | 1 + src/libs/actions/RequestConflictUtils.ts | 3 ++- src/libs/prepareRequestPayload/index.native.ts | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 4baa4c2db9ca..1d7daab58547 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3065,6 +3065,7 @@ const CONST = { ATTACHMENT_SOURCE_ATTRIBUTE: 'data-expensify-source', ATTACHMENT_ID_ATTRIBUTE: 'data-attachment-id', ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE: 'data-optimistic-src', + ADDITIONAL_ATTACHMENT_FILE_KEY_REGEX: /^file_\d+$/, ATTACHMENT_PREVIEW_ATTRIBUTE: 'src', ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE: 'data-name', ATTACHMENT_LOCAL_URL_PREFIX: ['blob:', 'file:'], diff --git a/src/libs/actions/RequestConflictUtils.ts b/src/libs/actions/RequestConflictUtils.ts index 18b06b4e8698..b034c8e3f936 100644 --- a/src/libs/actions/RequestConflictUtils.ts +++ b/src/libs/actions/RequestConflictUtils.ts @@ -2,6 +2,7 @@ import type {OpenReportParams, UpdateCommentParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; import type {ApiRequestCommandParameters} from '@libs/API/types'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type OnyxRequest from '@src/types/onyx/Request'; import type {AnyRequest, ConflictActionData} from '@src/types/onyx/Request'; @@ -299,7 +300,7 @@ function resolveEditCommentWithNewAddCommentRequest( // The queued request keeps its own files, so without dropping them the server would re-add attachments the edit removed. if (removedAttachmentSources.length > 0) { const data = currentAddComment.data; - const fileKeys = ['file', ...Object.keys(data).filter((key) => /^file_\d+$/.test(key))]; + const fileKeys = ['file', ...Object.keys(data).filter((key) => CONST.ADDITIONAL_ATTACHMENT_FILE_KEY_REGEX.test(key))]; const attachmentIDs = typeof data.attachmentIDs === 'string' ? data.attachmentIDs.split(',') : [data.attachmentID]; const keptAttachments = fileKeys .map((key, index) => ({file: data[key] as FileObject | undefined, attachmentID: attachmentIDs.at(index)})) diff --git a/src/libs/prepareRequestPayload/index.native.ts b/src/libs/prepareRequestPayload/index.native.ts index afca500563d2..97e78bd67d28 100644 --- a/src/libs/prepareRequestPayload/index.native.ts +++ b/src/libs/prepareRequestPayload/index.native.ts @@ -5,6 +5,7 @@ import ReceiptStorage from '@libs/ReceiptStorage'; import {logReceiptDropped} from '@libs/telemetry/ReceiptObservability'; import validateFormDataParameter from '@libs/validateFormDataParameter'; +import CONST from '@src/CONST'; import type {Receipt} from '@src/types/onyx/Transaction'; import RNFS from 'react-native-fs'; @@ -84,7 +85,7 @@ const prepareRequestPayload: PrepareRequestPayload = (command, data, initiatedOf } } - if ((key === 'file' || key.startsWith('file_')) && initiatedOffline) { + if ((key === 'file' || CONST.ADDITIONAL_ATTACHMENT_FILE_KEY_REGEX.test(key)) && initiatedOffline) { const {uri: path = '', source, name, type} = value as File; if (!source) { validateFormDataParameter(command, key, value); From 529469c6b604fc8f6d6a0d5b1ff3510c086532e2 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 16 Oct 2026 22:39:54 +0200 Subject: [PATCH 097/156] Simplify rewriting a queued request's attachments after an edit --- src/libs/actions/RequestConflictUtils.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/libs/actions/RequestConflictUtils.ts b/src/libs/actions/RequestConflictUtils.ts index b034c8e3f936..911f3f49c314 100644 --- a/src/libs/actions/RequestConflictUtils.ts +++ b/src/libs/actions/RequestConflictUtils.ts @@ -309,18 +309,11 @@ function resolveEditCommentWithNewAddCommentRequest( for (const key of fileKeys) { delete data[key]; } - delete data.attachmentID; - delete data.attachmentIDs; - - for (const [index, keptAttachment] of keptAttachments.entries()) { - data[index === 0 ? 'file' : `file_${index}`] = keptAttachment.file; - } - if (keptAttachments.length > 0) { - data.attachmentID = keptAttachments.at(0)?.attachmentID; - } - if (keptAttachments.length > 1) { - data.attachmentIDs = keptAttachments.map((keptAttachment) => keptAttachment.attachmentID).join(','); + for (const [index, {file}] of keptAttachments.entries()) { + data[index === 0 ? 'file' : `file_${index}`] = file; } + data.attachmentID = keptAttachments.at(0)?.attachmentID; + data.attachmentIDs = keptAttachments.length > 1 ? keptAttachments.map(({attachmentID}) => attachmentID).join(',') : undefined; if (keptAttachments.length === 0) { currentAddComment.command = WRITE_COMMANDS.ADD_COMMENT; } From 804aff59f07e1c44d83636dd90dd802a2ad372be Mon Sep 17 00:00:00 2001 From: "Rushat Gabhane (via MelvinBot)" Date: Fri, 16 Oct 2026 21:44:14 +0000 Subject: [PATCH 098/156] Fix ESLint errors in queued attachment edit handling Co-authored-by: Rushat Gabhane --- src/libs/actions/RequestConflictUtils.ts | 10 +++++++--- tests/unit/RequestConflictUtilsTest.ts | 3 +++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/RequestConflictUtils.ts b/src/libs/actions/RequestConflictUtils.ts index 911f3f49c314..29cbbf104f72 100644 --- a/src/libs/actions/RequestConflictUtils.ts +++ b/src/libs/actions/RequestConflictUtils.ts @@ -6,7 +6,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type OnyxRequest from '@src/types/onyx/Request'; import type {AnyRequest, ConflictActionData} from '@src/types/onyx/Request'; -import type {FileObject} from '@src/types/utils/Attachment'; import type {OnyxKey, OnyxUpdate} from 'react-native-onyx'; import type {TupleToUnion} from 'type-fest'; @@ -277,6 +276,11 @@ function resolveCommentDeletionConflicts(persistedRequests }; } +/** Reads the local URI of a queued file. Request data is untyped, so the shape is checked rather than cast. */ +function getQueuedFileURI(file: unknown): string { + return typeof file === 'object' && file !== null && 'uri' in file && typeof file.uri === 'string' ? file.uri : ''; +} + function resolveEditCommentWithNewAddCommentRequest( persistedRequests: Array>, parameters: UpdateCommentParams, @@ -303,8 +307,8 @@ function resolveEditCommentWithNewAddCommentRequest( const fileKeys = ['file', ...Object.keys(data).filter((key) => CONST.ADDITIONAL_ATTACHMENT_FILE_KEY_REGEX.test(key))]; const attachmentIDs = typeof data.attachmentIDs === 'string' ? data.attachmentIDs.split(',') : [data.attachmentID]; const keptAttachments = fileKeys - .map((key, index) => ({file: data[key] as FileObject | undefined, attachmentID: attachmentIDs.at(index)})) - .filter(({file}) => !!file && !removedAttachmentSources.includes(file.uri ?? '')); + .map((key, index) => ({file: data[key], attachmentID: attachmentIDs.at(index)})) + .filter(({file}) => !!file && !removedAttachmentSources.includes(getQueuedFileURI(file))); for (const key of fileKeys) { delete data[key]; diff --git a/tests/unit/RequestConflictUtilsTest.ts b/tests/unit/RequestConflictUtilsTest.ts index cca3e61bdb32..6df0193483f1 100644 --- a/tests/unit/RequestConflictUtilsTest.ts +++ b/tests/unit/RequestConflictUtilsTest.ts @@ -168,6 +168,7 @@ describe('RequestConflictUtils', () => { const persistedRequests = [ { command: 'AddTextAndAttachment', + // eslint-disable-next-line @typescript-eslint/naming-convention -- the API names extra files file_1, file_2, ... data: {reportActionID, reportComment: 'test', file: {uri: 'blob:a'}, file_1: {uri: 'blob:b'}, file_2: {uri: 'blob:c'}, attachmentID: '5', attachmentIDs: '5,6,7'}, }, ]; @@ -182,6 +183,7 @@ describe('RequestConflictUtils', () => { index: 0, request: { command: 'AddTextAndAttachment', + // eslint-disable-next-line @typescript-eslint/naming-convention -- the API names extra files file_1, file_2, ... data: {reportID: '1', reportActionID, reportComment: 'b removed', file: {uri: 'blob:a'}, file_1: {uri: 'blob:c'}, attachmentID: '5', attachmentIDs: '5,7'}, }, }); @@ -193,6 +195,7 @@ describe('RequestConflictUtils', () => { const persistedRequests = [ { command: 'AddTextAndAttachment', + // eslint-disable-next-line @typescript-eslint/naming-convention -- the API names extra files file_1, file_2, ... data: {reportActionID, reportComment: 'test', file: {uri: 'blob:a'}, file_1: {uri: 'blob:b'}, file_2: {uri: 'blob:c'}, attachmentID: '5', attachmentIDs: '5,6,7'}, }, ]; From 7be9669b495bf6d30606c5219c9ffc241c7f42f2 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 17:16:10 +0200 Subject: [PATCH 099/156] Replace the Concierge session context with a chat history context --- src/App.tsx | 4 +- .../inbox/ConciergeChatHistoryContext.tsx | 61 ++++++++ src/pages/inbox/ConciergeSessionContext.tsx | 134 ------------------ 3 files changed, 63 insertions(+), 136 deletions(-) create mode 100644 src/pages/inbox/ConciergeChatHistoryContext.tsx delete mode 100644 src/pages/inbox/ConciergeSessionContext.tsx diff --git a/src/App.tsx b/src/App.tsx index 5bf33e75d410..840e48ec867d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -49,7 +49,7 @@ import useDefaultDragAndDrop from './hooks/useDefaultDragAndDrop'; import HybridAppHandler from './HybridAppHandler'; import OnyxUpdateManager from './libs/actions/OnyxUpdateManager'; import './libs/HybridApp'; -import {ConciergeSessionProvider} from './pages/inbox/ConciergeSessionContext'; +import {ConciergeChatHistoryProvider} from './pages/inbox/ConciergeChatHistoryContext'; import './setup/backgroundLocationTrackingTask'; import './setup/backgroundTask'; import './setup/fraudProtection'; @@ -115,7 +115,7 @@ function App() { SafeArea, PopoverContextProvider, CurrentReportIDContextProvider, - ConciergeSessionProvider, + ConciergeChatHistoryProvider, ScrollOffsetContextProvider, PickerStateProvider, EnvironmentProvider, diff --git a/src/pages/inbox/ConciergeChatHistoryContext.tsx b/src/pages/inbox/ConciergeChatHistoryContext.tsx new file mode 100644 index 000000000000..ef755c61b537 --- /dev/null +++ b/src/pages/inbox/ConciergeChatHistoryContext.tsx @@ -0,0 +1,61 @@ +import useOnyx from '@hooks/useOnyx'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import type {PropsWithChildren} from 'react'; + +import React, {createContext, useContext, useState} from 'react'; + +type ConciergeChatHistoryStateContextType = { + /** Whether the main Concierge chat shows its earlier conversation instead of the welcome screen */ + showFullHistory: boolean; +}; + +type ConciergeChatHistoryActionsContextType = { + setShowFullHistory: (show: boolean) => void; +}; + +const ConciergeChatHistoryStateContext = createContext({ + showFullHistory: false, +}); + +const ConciergeChatHistoryActionsContext = createContext({ + setShowFullHistory: () => {}, +}); + +const accountIDSelector = (session: {accountID?: number} | undefined) => session?.accountID; + +/** + * Remembers whether the main Concierge chat history is expanded, so it stays expanded across the composer, the + * header and the list. Mounted at app level (App.tsx) so it never remounts. + */ +function ConciergeChatHistoryProvider({children}: PropsWithChildren) { + const [showFullHistory, setShowFullHistory] = useState(false); + + // The provider never remounts, so collapse the history when the user switches accounts. + const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); + const [prevAccountID, setPrevAccountID] = useState(accountID); + if (prevAccountID !== accountID) { + setPrevAccountID(accountID); + setShowFullHistory(false); + } + + const stateValue = {showFullHistory}; + const actionsValue = {setShowFullHistory}; + + return ( + + {children} + + ); +} + +function useConciergeChatHistoryState(): ConciergeChatHistoryStateContextType { + return useContext(ConciergeChatHistoryStateContext); +} + +function useConciergeChatHistoryActions(): ConciergeChatHistoryActionsContextType { + return useContext(ConciergeChatHistoryActionsContext); +} + +export {ConciergeChatHistoryProvider, useConciergeChatHistoryActions, useConciergeChatHistoryState}; diff --git a/src/pages/inbox/ConciergeSessionContext.tsx b/src/pages/inbox/ConciergeSessionContext.tsx deleted file mode 100644 index bed4a07da113..000000000000 --- a/src/pages/inbox/ConciergeSessionContext.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import useOnyx from '@hooks/useOnyx'; - -import {getServerAnchoredDBTime} from '@libs/NetworkState'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -import type {PropsWithChildren} from 'react'; - -import React, {createContext, useCallback, useContext, useMemo, useRef, useState} from 'react'; - -type ConciergeSessionStateContextType = { - sessionStartTime: string | null; - showFullHistory: boolean; - hadMessagesAtSessionStart: boolean; -}; - -type ConciergeSessionActionsContextType = { - startSession: (unreadBoundary?: string | null) => void; - /** Begins a brand new session, so the Concierge DM drops back to its welcome state */ - resetSession: () => void; - setShowFullHistory: (show: boolean) => void; - setHadMessagesAtSessionStart: (value: boolean) => void; -}; - -const ConciergeSessionStateContext = createContext({ - sessionStartTime: null, - showFullHistory: false, - hadMessagesAtSessionStart: false, -}); - -const ConciergeSessionActionsContext = createContext({ - startSession: () => {}, - resetSession: () => {}, - setShowFullHistory: () => {}, - setHadMessagesAtSessionStart: () => {}, -}); - -const accountIDSelector = (session: {accountID?: number} | undefined) => session?.accountID; - -/** - * Provides a stable sessionStartTime for the main Concierge DM. - * Mounted at app level (App.tsx) so it never remounts. - * - * Session start is triggered eagerly by consumers (via startSession()) - * using useLayoutEffect, so the timestamp is set before the browser paints. - * - * Session reset uses a staleness check: if the session was created more - * than CONCIERGE_SESSION_EXPIRATION_MS (2 hours) ago, it is discarded and - * a new session begins. Brief detours (settings, workspace links, app - * going to background) preserve the session; closing the app or switching - * accounts resets it naturally. - */ -function ConciergeSessionProvider({children}: PropsWithChildren) { - const [sessionStartTime, setSessionStartTime] = useState(null); - const [showFullHistory, setShowFullHistory] = useState(false); - const [hadMessagesAtSessionStart, setHadMessagesAtSessionStart] = useState(false); - - // Tracks when the session was actually created (Date.now()), separate - // from sessionStartTime which may be an older unread boundary used for - // display filtering. The age check uses this value so that an old - // lastReadTime boundary doesn't cause premature expiration. - const sessionCreatedAtRef = useRef(null); - - // True once the user presses New chat, until the next session begins. - const isSessionResetByUserRef = useRef(false); - - // Reset the session when the user switches accounts. The provider is - // mounted at the app level and never remounts, so without this the - // previous user's session state would leak into the new account. - const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); - const [prevAccountID, setPrevAccountID] = useState(accountID); - if (prevAccountID !== accountID) { - setPrevAccountID(accountID); - if (sessionStartTime !== null) { - setSessionStartTime(null); - setShowFullHistory(false); - setHadMessagesAtSessionStart(false); - } - } - - const startSession = useCallback( - (unreadBoundary?: string | null) => { - const sessionCreatedAt = sessionCreatedAtRef.current; - if (sessionStartTime !== null && sessionCreatedAt !== null && Date.now() - sessionCreatedAt < CONST.CONCIERGE_SESSION_EXPIRATION_MS) { - // Within an active session, keep the existing boundary unless a better - // (earlier) unread boundary resolves after the session was created. On a - // cold open the session can lock to `now` before the unread anchor resolves; - // when it arrives we pull sessionStartTime back so the notification message - // isn't hidden behind "Show full history". The session age (sessionCreatedAtRef) - // is unchanged — only the display boundary is refined. - if (unreadBoundary && !isSessionResetByUserRef.current) { - setSessionStartTime((prev) => (prev && unreadBoundary < prev ? unreadBoundary : prev)); - } - return; - } - - const now = getServerAnchoredDBTime(); - sessionCreatedAtRef.current = Date.now(); - isSessionResetByUserRef.current = false; - setSessionStartTime(unreadBoundary && unreadBoundary < now ? unreadBoundary : now); - setShowFullHistory(false); - setHadMessagesAtSessionStart(false); - }, - [sessionStartTime], - ); - - const resetSession = () => { - sessionCreatedAtRef.current = Date.now(); - isSessionResetByUserRef.current = true; - setSessionStartTime(getServerAnchoredDBTime()); - setShowFullHistory(false); - setHadMessagesAtSessionStart(false); - }; - - const stateValue = useMemo(() => ({sessionStartTime, showFullHistory, hadMessagesAtSessionStart}), [sessionStartTime, showFullHistory, hadMessagesAtSessionStart]); - const actionsValue = useMemo(() => ({startSession, resetSession, setShowFullHistory, setHadMessagesAtSessionStart}), [startSession, resetSession]); - - return ( - - {children} - - ); -} - -function useConciergeSessionState(): ConciergeSessionStateContextType { - return useContext(ConciergeSessionStateContext); -} - -function useConciergeSessionActions(): ConciergeSessionActionsContextType { - return useContext(ConciergeSessionActionsContext); -} - -export {ConciergeSessionProvider, useConciergeSessionActions, useConciergeSessionState}; From f525f7b5f14de66a6fc9a0385fd4e08ecb95bdf4 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 17:16:28 +0200 Subject: [PATCH 100/156] Stop starting and resetting a Concierge session in the main chat --- src/CONST/index.ts | 1 - src/hooks/useStartConciergeSession.ts | 58 ------------------- src/pages/inbox/HeaderView.tsx | 6 +- src/pages/inbox/ReportActions.tsx | 3 +- .../report/ConciergeChatHistoryToggle.tsx | 4 +- .../ReportActionCompose/useComposerSubmit.ts | 4 +- .../report/ReportActionsSkeletonGuard.tsx | 23 +------- 7 files changed, 9 insertions(+), 90 deletions(-) delete mode 100644 src/hooks/useStartConciergeSession.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 1e3f9854e8af..dd73a18a45f4 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1487,7 +1487,6 @@ const CONST = { EXAMPLE_PHONE_NUMBER: '+15005550006', FORMATTED_EXAMPLE_PHONE_NUMBER: '+1-(201)-867-5309', CONCIERGE_CHAT_NAME: 'Concierge', - CONCIERGE_SESSION_EXPIRATION_MS: 2 * 3600 * 1000, // 2 hours CLOUDFRONT_URL, EMPTY_ARRAY, EMPTY_OBJECT, diff --git a/src/hooks/useStartConciergeSession.ts b/src/hooks/useStartConciergeSession.ts deleted file mode 100644 index ce9459b25fd0..000000000000 --- a/src/hooks/useStartConciergeSession.ts +++ /dev/null @@ -1,58 +0,0 @@ -import {useConciergeSessionActions} from '@pages/inbox/ConciergeSessionContext'; - -import {useLayoutEffect} from 'react'; - -import type {ReportActionsReadinessSignals} from './useReportActionsListModel'; - -import {useCurrentReportIDState} from './useCurrentReportID'; - -type UseStartConciergeSessionParams = Pick & { - /** The ID of the report to display actions for */ - reportID: string; - - /** Whether actions are already cached, excluding the synthetic CREATED action */ - hasCachedReportActions: boolean; -}; - -/** - * Starts the Concierge main-DM session. Subscribes to currentReportID/startSession itself so the pipeline - * doesn't carry them; currentReportID re-renders the guard but never the list. - */ -function useStartConciergeSession({reportID, isConciergeMainDM, oldestUnreadReportAction, hasOnceLoadedReportActions, hasCachedReportActions}: UseStartConciergeSessionParams) { - const {currentReportID} = useCurrentReportIDState(); - const {startSession} = useConciergeSessionActions(); - - // hasOnceLoadedReportActions is RAM-only and resets to falsy on a page - // refresh, but cached report actions persist in Onyx. Gating the session - // start on it alone would leave sessionStartTime null until openReport - // returns, during which filterActions collapses the cached history down to - // just the synthetic CREATED action (an almost-empty chat flash). Start the - // session as soon as cached actions exist so messages render immediately on - // refresh, matching the concierge skeleton condition. - const canStartConciergeSession = !!hasOnceLoadedReportActions || hasCachedReportActions; - - useLayoutEffect(() => { - if (!isConciergeMainDM || !canStartConciergeSession) { - return; - } - // Anchor the session to the unread action's created time, a stable pre-read - // boundary, rather than the live report.lastReadTime which readNewestAction - // bumps to `now` when the report opens. Re-runs when oldestUnreadReportAction - // resolves so a session that locked to `now` before the anchor loaded is - // pulled back to keep the unread message visible. - startSession(oldestUnreadReportAction?.created); - }, [isConciergeMainDM, startSession, canStartConciergeSession, oldestUnreadReportAction?.created]); - - // On native the component stays mounted in the navigation stack, so the - // effect above never re-fires (its isConciergeMainDM dep is always true). - // Re-trigger startSession when the globally-focused report matches this - // report so the session age check runs after navigating away and back. - useLayoutEffect(() => { - if (!isConciergeMainDM || !canStartConciergeSession || currentReportID !== reportID) { - return; - } - startSession(oldestUnreadReportAction?.created); - }, [currentReportID, reportID, isConciergeMainDM, canStartConciergeSession, startSession, oldestUnreadReportAction?.created]); -} - -export default useStartConciergeSession; diff --git a/src/pages/inbox/HeaderView.tsx b/src/pages/inbox/HeaderView.tsx index 5cac295caf20..fc479f505791 100644 --- a/src/pages/inbox/HeaderView.tsx +++ b/src/pages/inbox/HeaderView.tsx @@ -105,7 +105,7 @@ import {latestCalendlyCallSelector} from '@selectors/ReportNameValuePairs'; import React, {useMemo} from 'react'; import {Keyboard, View} from 'react-native'; -import {useConciergeSessionActions} from './ConciergeSessionContext'; +import {useConciergeChatHistoryActions} from './ConciergeChatHistoryContext'; type HeaderViewProps = { /** Toggles the navigationMenu open and closed */ @@ -161,7 +161,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const isSelfDM = isSelfDMReportUtils(report); const isGroupChat = isGroupChatReportUtils(report) || isDeprecatedGroupDM(report, isReportArchived); const isConciergeChat = isConciergeChatReport(report, conciergeReportID); - const {resetSession: resetConciergeSession} = useConciergeSessionActions(); + const {setShowFullHistory: setConciergeShowFullHistory} = useConciergeChatHistoryActions(); const isConciergeThread = !!conciergeReportID && report?.parentReportID === conciergeReportID; const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [onboarding] = useOnyx(ONYXKEYS.NVP_ONBOARDING); @@ -322,7 +322,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) ); const startNewConciergeChat = () => { - resetConciergeSession(); + setConciergeShowFullHistory(false); Navigation.navigate(getReportRouteForCurrentContext({reportID: conciergeReportID})); }; diff --git a/src/pages/inbox/ReportActions.tsx b/src/pages/inbox/ReportActions.tsx index e864155f7bc3..71d15523c1d2 100644 --- a/src/pages/inbox/ReportActions.tsx +++ b/src/pages/inbox/ReportActions.tsx @@ -64,8 +64,7 @@ function ReportActions() { // branches below) — matching the previous behavior, where this skeleton lived inside the // chat-only ReportActionsView. // - // Concierge is excluded so the body still mounts under the app-load skeleton, seeding sessionStartTime - // before content appeared. + // Concierge is excluded so its welcome screen shows while the app loads. const isConciergeMainDM = isConciergeChatReport(report, conciergeReportID); const shouldShowAppLoadSkeleton = isAppLoadPending && !isOffline && !!report && !shouldWaitForTransactions && !shouldDisplayMoneyRequestActionsList && !isConciergeMainDM; diff --git a/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx b/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx index 68994fcdeb3b..b0974e396cf8 100644 --- a/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx +++ b/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx @@ -7,7 +7,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; -import {useConciergeSessionActions} from '@pages/inbox/ConciergeSessionContext'; +import {useConciergeChatHistoryActions} from '@pages/inbox/ConciergeChatHistoryContext'; import CONST from '@src/CONST'; @@ -39,7 +39,7 @@ function ConciergeChatHistoryToggle({reportID, hasPreviousMessages, shouldShowFu const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['UpArrow', 'DownArrow']); const {isAskConciergeChat} = useConciergeAskState(reportID); - const {setShowFullHistory} = useConciergeSessionActions(); + const {setShowFullHistory} = useConciergeChatHistoryActions(); const route = useRoute(); const navigation = useNavigation(); diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts index a26e3b2a937f..ae52f6798535 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts @@ -17,7 +17,7 @@ import getSendMessageSource from '@libs/telemetry/getSendMessageSource'; import {getSendMessageVisibleSpanID} from '@libs/telemetry/sendMessageSpans'; import {useActionListContext} from '@pages/inbox/ActionListContext'; -import {useConciergeSessionActions} from '@pages/inbox/ConciergeSessionContext'; +import {useConciergeChatHistoryActions} from '@pages/inbox/ConciergeChatHistoryContext'; import {setIsComposerFullSize} from '@userActions/Report'; @@ -47,7 +47,7 @@ function useComposerSubmit(reportID: string) { const {isEditingInComposer, effectiveDraft, didResetComposerHeightWhileEditing, editingState} = useComposerEditState(); const {publishDraft, setDidResetComposerHeightWhileEditing} = useComposerEditActions(); const {scrollOffsetRef} = useActionListContext(); - const {setShowFullHistory} = useConciergeSessionActions(); + const {setShowFullHistory} = useConciergeChatHistoryActions(); const {report, effectiveTransactionThreadReportID} = useComposerReportData(reportID); const [targetReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${effectiveTransactionThreadReportID ?? reportID}`); diff --git a/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx b/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx index ec6492c291b9..f5a02262231b 100644 --- a/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx +++ b/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx @@ -4,7 +4,6 @@ import useCopySelectionEvent from '@hooks/useCopySelectionEvent'; import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; import usePendingConciergeResponse from '@hooks/usePendingConciergeResponse'; import useReportActionsListModel from '@hooks/useReportActionsListModel'; -import useStartConciergeSession from '@hooks/useStartConciergeSession'; import CONST from '@src/CONST'; @@ -38,32 +37,12 @@ function ReportActionsSkeletonGuard({reportID, children}: ReportActionsSkeletonG const {readinessSignals, state, actions} = useReportActionsListModel(reportID, isReportLoadPending); const {shouldShowLoadingSkeleton, shouldShowDerivedTimingSkeleton} = computeReportActionsSkeletonState(readinessSignals); - const { - isConciergeMainDM, - oldestUnreadReportAction, - hasOnceLoadedReportActions, - hasCachedReportActions, - isMissingReportActions, - hasOlderActions, - hasNewerActions, - isOffline, - isLoadingOlderReportActions, - hasLoadingOlderReportActionsError, - oldestReportActionID, - } = readinessSignals; + const {isMissingReportActions, hasOlderActions, hasNewerActions, isOffline, isLoadingOlderReportActions, hasLoadingOlderReportActionsError, oldestReportActionID} = readinessSignals; // Side effects that must run whenever the chat list is shown, including while the skeleton renders. useCopySelectionEvent(); usePendingConciergeResponse(reportID); - useStartConciergeSession({ - reportID, - isConciergeMainDM, - oldestUnreadReportAction, - hasOnceLoadedReportActions, - hasCachedReportActions, - }); - useBackfillWhenNoVisibleActions({ reportID, isMissingReportActions, From a1b73de0bce40962c809922ce1526e151d5a80cf Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 17:17:59 +0200 Subject: [PATCH 101/156] Show the main Concierge chat unfiltered once its history is expanded --- src/hooks/useConciergeSessionStartTime.ts | 18 ---- .../useConciergeSidePanelReportActions.ts | 97 +++---------------- src/hooks/useReportActionsListModel.ts | 54 ++++------- src/hooks/useReportActionsVisibility.ts | 29 +++--- src/pages/inbox/report/ReportActionsList.tsx | 18 ++-- .../inbox/report/ReportActionsListContext.tsx | 19 +--- 6 files changed, 58 insertions(+), 177 deletions(-) delete mode 100644 src/hooks/useConciergeSessionStartTime.ts diff --git a/src/hooks/useConciergeSessionStartTime.ts b/src/hooks/useConciergeSessionStartTime.ts deleted file mode 100644 index 627c7bde9048..000000000000 --- a/src/hooks/useConciergeSessionStartTime.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; - -import useIsInSidePanel from './useIsInSidePanel'; -import useSidePanelState from './useSidePanelState'; - -/** - * Returns the Concierge session start time from the context that actually tracks it: SidePanelStateContext in the - * side panel, ConciergeSessionContext in the main DM. Each is null on the other surface, so always read via this - * hook. Only meaningful in a Concierge chat - callers gate with e.g. isConciergeHiddenHistory. - */ -function useConciergeSessionStartTime(): string | null { - const isInSidePanel = useIsInSidePanel(); - const {sessionStartTime: mainDMSessionStartTime} = useConciergeSessionState(); - const {sessionStartTime: sidePanelSessionStartTime} = useSidePanelState(); - return isInSidePanel ? sidePanelSessionStartTime : mainDMSessionStartTime; -} - -export default useConciergeSessionStartTime; diff --git a/src/hooks/useConciergeSidePanelReportActions.ts b/src/hooks/useConciergeSidePanelReportActions.ts index 4d78e42fc0c4..695f9083f3e7 100644 --- a/src/hooks/useConciergeSidePanelReportActions.ts +++ b/src/hooks/useConciergeSidePanelReportActions.ts @@ -1,24 +1,12 @@ import DateUtils from '@libs/DateUtils'; -import {isCreatedAction, isCurrentUserPendingAddAction, isDeletedParentAction} from '@libs/ReportActionsUtils'; +import {isCreatedAction, isCurrentUserPendingAddAction} from '@libs/ReportActionsUtils'; import {buildConciergeGreetingReportAction} from '@libs/ReportUtils'; -import CONST from '@src/CONST'; import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import {useCallback, useLayoutEffect, useMemo, useState} from 'react'; - -/** An OPEN task assigned to the current user. Canceled tasks stay OPEN optimistically, so deleted parents are excluded. */ -function isOpenChildTaskAction(action: OnyxTypes.ReportAction, currentUserAccountID: number): boolean { - return ( - action.childType === CONST.REPORT.TYPE.TASK && - action.childManagerAccountID === currentUserAccountID && - action.childStateNum === CONST.REPORT.STATE_NUM.OPEN && - action.childStatusNum === CONST.REPORT.STATUS_NUM.OPEN && - !isDeletedParentAction(action) - ); -} +import {useCallback, useMemo, useState} from 'react'; type UseConciergeSidePanelReportActionsParams = { report: OnyxEntry; @@ -31,11 +19,6 @@ type UseConciergeSidePanelReportActionsParams = { currentUserAccountID: number; greetingText: string; loadOlderChats: (force?: boolean) => void; - isConciergeMainDM?: boolean; - showFullHistory?: boolean; - onSetShowFullHistory?: (show: boolean) => void; - hadMessagesAtSessionStart?: boolean; - onSetHadMessagesAtSessionStart?: (value: boolean) => void; }; function useConciergeSidePanelReportActions({ @@ -49,18 +32,8 @@ function useConciergeSidePanelReportActions({ currentUserAccountID, greetingText, loadOlderChats, - isConciergeMainDM, - showFullHistory: externalShowFullHistory, - onSetShowFullHistory, - hadMessagesAtSessionStart: externalHadMessagesAtSessionStart, - onSetHadMessagesAtSessionStart, }: UseConciergeSidePanelReportActionsParams) { - const [localShowFullHistory, setLocalShowFullHistory] = useState(false); - const [localHadMessagesAtSessionStart, setLocalHadMessagesAtSessionStart] = useState( - () => - externalHadMessagesAtSessionStart ?? - (!!isConciergeMainDM && !!sessionStartTime && visibleReportActions.some((action) => !isCreatedAction(action) && action.created >= sessionStartTime)), - ); + const [showFullHistory, setShowFullHistory] = useState(false); const [prevSessionStartTime, setPrevSessionStartTime] = useState(sessionStartTime); const [prevHasUserSentMessage, setPrevHasUserSentMessage] = useState(hasUserSentMessage); @@ -68,19 +41,13 @@ function useConciergeSidePanelReportActions({ // them sticky rather than re-deriving visibility from the timestamp. const [sessionSentActionIDs, setSessionSentActionIDs] = useState>(() => new Set()); - const hadMessagesAtSessionStart = localHadMessagesAtSessionStart; - const showFullHistory = externalShowFullHistory ?? localShowFullHistory; - const setShowFullHistory = onSetShowFullHistory ?? setLocalShowFullHistory; - if (prevSessionStartTime !== sessionStartTime) { setPrevSessionStartTime(sessionStartTime); - setLocalShowFullHistory(false); - const messagesExistAtStart = !!isConciergeMainDM && !!sessionStartTime && visibleReportActions.some((action) => !isCreatedAction(action) && action.created >= sessionStartTime); - setLocalHadMessagesAtSessionStart(messagesExistAtStart); + setShowFullHistory(false); setSessionSentActionIDs(new Set()); } else if (prevHasUserSentMessage && !hasUserSentMessage) { setPrevHasUserSentMessage(hasUserSentMessage); - setLocalShowFullHistory(false); + setShowFullHistory(false); } else if (prevHasUserSentMessage !== hasUserSentMessage) { setPrevHasUserSentMessage(hasUserSentMessage); } @@ -93,10 +60,6 @@ function useConciergeSidePanelReportActions({ setSessionSentActionIDs((prev) => new Set([...prev, ...pendingSentActionIDs])); } - useLayoutEffect(() => { - onSetHadMessagesAtSessionStart?.(localHadMessagesAtSessionStart); - }, [localHadMessagesAtSessionStart, onSetHadMessagesAtSessionStart]); - // Check if the user had sent any message BEFORE this session started. // Uses sessionStartTime as the boundary — any user message created before the // panel opened is a pre-session message, regardless of when it was loaded. @@ -116,27 +79,10 @@ function useConciergeSidePanelReportActions({ return hasUserMessageInLoadedSet || hasOlderActions; }, [isConciergeHiddenHistory, visibleReportActions, currentUserAccountID, sessionStartTime, hasOlderActions]); - // Main DM only: check if there are any messages (from any actor) after sessionStartTime. - // When true, we have unread content to display and should not enter welcome mode. - const hasMessagesInSession = useMemo(() => { - if (!isConciergeMainDM || !isConciergeHiddenHistory || !sessionStartTime) { - return false; - } - return visibleReportActions.some((action) => !isCreatedAction(action) && action.created >= sessionStartTime); - }, [isConciergeMainDM, isConciergeHiddenHistory, visibleReportActions, sessionStartTime]); - - // Main DM only: the welcome state must stand down for a pinned task, or `filterActions` returns before it renders. - const hasOpenChildTask = useMemo(() => { - if (!isConciergeMainDM || !isConciergeHiddenHistory) { - return false; - } - return visibleReportActions.some((action) => isOpenChildTaskAction(action, currentUserAccountID)); - }, [isConciergeMainDM, isConciergeHiddenHistory, visibleReportActions, currentUserAccountID]); - // A re-stamp flips `hasUserSentMessage` too, so the sticky set is what keeps the welcome state from coming back. const hasSentInSession = hasUserSentMessage || sessionSentActionIDs.size > 0; - const showConciergeSidePanelWelcome = isConciergeHiddenHistory && hadUserMessageAtSessionStart && !hasSentInSession && !showFullHistory && !hasMessagesInSession && !hasOpenChildTask; - const showConciergeGreeting = isConciergeHiddenHistory && hadUserMessageAtSessionStart && !showFullHistory && (!isConciergeMainDM || !hadMessagesAtSessionStart); + const showConciergeSidePanelWelcome = isConciergeHiddenHistory && hadUserMessageAtSessionStart && !hasSentInSession && !showFullHistory; + const showConciergeGreeting = isConciergeHiddenHistory && hadUserMessageAtSessionStart && !showFullHistory; const conciergeGreetingAction = useMemo(() => { if (!showConciergeGreeting) { @@ -150,7 +96,7 @@ function useConciergeSidePanelReportActions({ // Used to filter actions so that only messages from the first user message // onwards are shown (upstream side panel behavior). const firstUserMessageCreated = useMemo(() => { - if (isConciergeMainDM || showConciergeSidePanelWelcome || !isConciergeHiddenHistory || !hasUserSentMessage || !sessionStartTime) { + if (showConciergeSidePanelWelcome || !isConciergeHiddenHistory || !hasUserSentMessage || !sessionStartTime) { return undefined; } return reportActions.reduce((earliest, action) => { @@ -163,7 +109,7 @@ function useConciergeSidePanelReportActions({ } return !earliest || action.created < earliest ? action.created : earliest; }, undefined); - }, [isConciergeMainDM, showConciergeSidePanelWelcome, isConciergeHiddenHistory, hasUserSentMessage, sessionStartTime, reportActions, currentUserAccountID]); + }, [showConciergeSidePanelWelcome, isConciergeHiddenHistory, hasUserSentMessage, sessionStartTime, reportActions, currentUserAccountID]); const isCurrentSessionAction = useCallback( (action: OnyxTypes.ReportAction): boolean => { @@ -174,15 +120,6 @@ function useConciergeSidePanelReportActions({ if (sessionSentActionIDs.has(action.reportActionID)) { return true; } - if (isConciergeMainDM) { - // Pin a still-open task so collapsing read history never buries something the user has to act on. - return ( - isCreatedAction(action) || - isCurrentUserPendingAddAction(action, currentUserAccountID) || - isOpenChildTaskAction(action, currentUserAccountID) || - action.created >= sessionStartTime - ); - } if (!firstUserMessageCreated) { return false; } @@ -197,7 +134,7 @@ function useConciergeSidePanelReportActions({ (action.created >= sessionStartTime && (!isFromCurrentUser || action.created >= firstUserMessageCreated)) ); }, - [sessionStartTime, isConciergeMainDM, firstUserMessageCreated, currentUserAccountID, sessionSentActionIDs], + [sessionStartTime, firstUserMessageCreated, currentUserAccountID, sessionSentActionIDs], ); const filterActions = useCallback( @@ -216,7 +153,7 @@ function useConciergeSidePanelReportActions({ return actions; } const filtered = actions.filter(isCurrentSessionAction); - if (filtered.length === 0 && !isConciergeMainDM) { + if (filtered.length === 0) { // Side panel: nothing matched the current session yet (e.g. just after reopen, before the new // message propagates). Show the greeting instead of `actions` to avoid flashing stale history. if (conciergeGreetingAction) { @@ -231,16 +168,7 @@ function useConciergeSidePanelReportActions({ } return filtered; }, - [ - showConciergeSidePanelWelcome, - conciergeGreetingAction, - isConciergeHiddenHistory, - showFullHistory, - sessionStartTime, - isCurrentSessionAction, - hadUserMessageAtSessionStart, - isConciergeMainDM, - ], + [showConciergeSidePanelWelcome, conciergeGreetingAction, isConciergeHiddenHistory, showFullHistory, sessionStartTime, isCurrentSessionAction, hadUserMessageAtSessionStart], ); const filteredVisibleActions = useMemo(() => filterActions(visibleReportActions), [filterActions, visibleReportActions]); @@ -269,4 +197,3 @@ function useConciergeSidePanelReportActions({ } export default useConciergeSidePanelReportActions; -export {isOpenChildTaskAction}; diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index 6de80a82b8de..861a7aba0918 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -3,7 +3,7 @@ import {canUserPerformWriteAction, isReportTransactionThread as isReportTransact import type {ReportsSplitNavigatorParamList} from '@navigation/types'; -import {useConciergeSessionActions, useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; +import {useConciergeChatHistoryActions, useConciergeChatHistoryState} from '@pages/inbox/ConciergeChatHistoryContext'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -23,9 +23,9 @@ import useReportIsArchived from './useReportIsArchived'; /** * Single read/derive pipeline for the report-actions list: the subscriptions and derivations the skeleton - * decision and the list render both need, computed once. Side-effect-free (no writes/navigation/telemetry/ - * session-start). The guard calls it once and passes `state`/`actions` via `ReportActionsListStateContext` - * and `ReportActionsListActionsContext` so the content doesn't re-subscribe. + * decision and the list render both need, computed once. Side-effect-free (no writes/navigation/telemetry). + * The guard calls it once and passes `state`/`actions` via `ReportActionsListStateContext` and + * `ReportActionsListActionsContext` so the content doesn't re-subscribe. */ function useReportActionsListModel(reportID: string, isReportLoadPending: boolean) { const {isOffline} = useNetworkWithOfflineStatus(); @@ -60,8 +60,8 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const isLoadingOlderReportActions = reportLoadingState?.isLoadingOlderReportActions; const hasLoadingOlderReportActionsError = reportLoadingState?.hasLoadingOlderReportActionsError; - const {sessionStartTime, showFullHistory: conciergeShowFullHistory, hadMessagesAtSessionStart: conciergeHadMessagesAtSessionStart} = useConciergeSessionState(); - const {setShowFullHistory: setConciergeShowFullHistory, setHadMessagesAtSessionStart: setConciergeHadMessagesAtSessionStart} = useConciergeSessionActions(); + const {showFullHistory: conciergeShowFullHistory} = useConciergeChatHistoryState(); + const {setShowFullHistory: setConciergeShowFullHistory} = useConciergeChatHistoryActions(); const isReportTransactionThread = isReportTransactionThreadUtil(report); const shouldBeAlignedToTop = shouldReportAlignToTop(report, parentReportAction); @@ -83,37 +83,23 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea newestFetchedReportActionID: reportPaginationState?.newestFetchedReportActionID, }); - const { - sortedReportActions, - sortedVisibleReportActions, - isConciergeMainDM, - isConciergeHiddenHistory, - showConciergeSidePanelWelcome, - showFullHistory, - hasPreviousMessages, - handleShowPreviousMessages, - } = useReportActionsVisibility({ - reportID, - reportActions, - allReportActions, - canPerformWriteAction, - hasOlderActions, - loadOlderChats, - mainDMSessionStartTime: sessionStartTime, - conciergeShowFullHistory: conciergeShowFullHistory || !!reportActionIDFromRoute, - setConciergeShowFullHistory, - conciergeHadMessagesAtSessionStart, - setConciergeHadMessagesAtSessionStart, - }); + const {sortedReportActions, sortedVisibleReportActions, isConciergeHiddenHistory, showConciergeSidePanelWelcome, showFullHistory, hasPreviousMessages, handleShowPreviousMessages} = + useReportActionsVisibility({ + reportID, + reportActions, + allReportActions, + canPerformWriteAction, + hasOlderActions, + loadOlderChats, + conciergeShowFullHistory: conciergeShowFullHistory || !!reportActionIDFromRoute, + setConciergeShowFullHistory, + }); const isMissingReportActions = sortedVisibleReportActions.length === 0; const isSingleExpenseReport = reportPreviewAction?.childMoneyRequestCount === 1; - // allReportActions excludes the synthetic CREATED action always injected for Concierge, so this is - // false only on a genuinely cold load with no cached history. - const hasCachedReportActions = allReportActions.length > 0; // Readiness signals for the skeleton decision (some also read by the guard's effect hooks). Off the - // context so their churn (loading, app-load, concierge-session, navigation) never re-renders the list. + // context so their churn (loading, app-load, navigation) never re-renders the list. const readinessSignals = { report, reportResult, @@ -137,14 +123,12 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea isSingleExpenseReport, isMissingReportActions, isConciergeHiddenHistory, - isConciergeMainDM, - hasCachedReportActions, showConciergeSidePanelWelcome, derivedIsEmptyReport, }; // The render state slice on `ReportActionsListStateContext`; this is what drives list re-renders. - // Ambient state (network, route, archived, concierge session) is read locally in the content instead + // Ambient state (network, route, archived, concierge chat history) is read locally in the content instead // of carried here. const state = { report, diff --git a/src/hooks/useReportActionsVisibility.ts b/src/hooks/useReportActionsVisibility.ts index b99e6d56d5e7..8df204f71246 100644 --- a/src/hooks/useReportActionsVisibility.ts +++ b/src/hooks/useReportActionsVisibility.ts @@ -24,11 +24,8 @@ type UseReportActionsVisibilityParams = { canPerformWriteAction: boolean; hasOlderActions: boolean; loadOlderChats: (force?: boolean) => void; - mainDMSessionStartTime?: string | null; conciergeShowFullHistory?: boolean; setConciergeShowFullHistory?: (show: boolean) => void; - conciergeHadMessagesAtSessionStart?: boolean; - setConciergeHadMessagesAtSessionStart?: (value: boolean) => void; }; type UseReportActionsVisibilityResult = { @@ -50,11 +47,8 @@ function useReportActionsVisibility({ canPerformWriteAction, hasOlderActions, loadOlderChats, - mainDMSessionStartTime, conciergeShowFullHistory, setConciergeShowFullHistory, - conciergeHadMessagesAtSessionStart, - setConciergeHadMessagesAtSessionStart, }: UseReportActionsVisibilityParams): UseReportActionsVisibilityResult { const {isOffline} = useNetwork(); const {translate} = useLocalize(); @@ -69,10 +63,9 @@ function useReportActionsVisibility({ const isInSidePanel = useIsInSidePanel(); const isConciergeSidePanel = isInSidePanel && isConciergeChatReport(report, conciergeReportID); const isConciergeMainDM = !isInSidePanel && isConciergeChatReport(report, conciergeReportID); - const isConciergeHiddenHistory = isConciergeSidePanel || isConciergeMainDM; + const isConciergeHiddenHistory = isConciergeSidePanel; - const {sessionStartTime: sidePanelSessionStartTime} = useSidePanelState(); - const sessionStartTime = isConciergeSidePanel ? sidePanelSessionStartTime : (mainDMSessionStartTime ?? null); + const {sessionStartTime} = useSidePanelState(); const hasUserSentMessage = isConciergeHiddenHistory && sessionStartTime @@ -121,13 +114,15 @@ function useReportActionsVisibility({ currentUserAccountID, greetingText: translate('common.concierge.greeting'), loadOlderChats, - isConciergeMainDM, - showFullHistory: isConciergeMainDM ? conciergeShowFullHistory : undefined, - onSetShowFullHistory: isConciergeMainDM ? setConciergeShowFullHistory : undefined, - hadMessagesAtSessionStart: isConciergeMainDM ? conciergeHadMessagesAtSessionStart : undefined, - onSetHadMessagesAtSessionStart: isConciergeMainDM ? setConciergeHadMessagesAtSessionStart : undefined, }); + const hasConciergeMainDMHistory = isConciergeMainDM && (hasOlderActions || visibleReportActions.some((action) => !isCreatedAction(action))); + + const showConciergeMainDMHistory = () => { + setConciergeShowFullHistory?.(true); + loadOlderChats(true); + }; + return { sortedReportActions: filteredReportActions, sortedVisibleReportActions: filteredVisibleActions, @@ -135,9 +130,9 @@ function useReportActionsVisibility({ isConciergeMainDM, isConciergeHiddenHistory, showConciergeSidePanelWelcome, - showFullHistory, - hasPreviousMessages, - handleShowPreviousMessages, + showFullHistory: isConciergeMainDM ? !!conciergeShowFullHistory : showFullHistory, + hasPreviousMessages: isConciergeMainDM ? hasConciergeMainDMHistory : hasPreviousMessages, + handleShowPreviousMessages: isConciergeMainDM ? showConciergeMainDMHistory : handleShowPreviousMessages, }; } diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index f750b545ecac..07b90ad1fe3f 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -5,7 +5,6 @@ import {ReportActionsAnimatedSkeletonCover} from '@components/ReportActionsSkele import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useConciergeAskState from '@hooks/useConciergeAskState'; -import useConciergeSessionStartTime from '@hooks/useConciergeSessionStartTime'; import useEnvironment from '@hooks/useEnvironment'; import useLinkedMessageOfflineLoading from '@hooks/useLinkedMessageOfflineLoading'; import useLocalize from '@hooks/useLocalize'; @@ -18,6 +17,7 @@ import useReportScrollManager from '@hooks/useReportScrollManager'; import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useRetireMerchantRuleSuggestionOnLeave from '@hooks/useRetireMerchantRuleSuggestionOnLeave'; +import useSidePanelState from '@hooks/useSidePanelState'; import useThemeStyles from '@hooks/useThemeStyles'; import useUnreadMarker from '@hooks/useUnreadMarker'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -147,7 +147,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const {isOffline} = useNetwork(); const route = useRoute>(); const reportActionIDFromRoute = route?.params?.reportActionID; - const sessionStartTime = useConciergeSessionStartTime(); + const {sessionStartTime} = useSidePanelState(); const {shouldShowWelcome: shouldShowConciergeWelcome, isHistoryExpanded} = useConciergeAskState(reportID); const didLayout = useRef(false); @@ -506,12 +506,14 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const listHeaderComponent = ( <> - + {isHistoryExpanded && ( + + )} Date: Tue, 20 Oct 2026 17:18:33 +0200 Subject: [PATCH 102/156] Decide the Concierge welcome screen without a session timestamp --- src/hooks/useConciergeAskState.ts | 59 ++++++++----------- .../useShouldSuppressConciergeIndicators.tsx | 14 ++--- 2 files changed, 32 insertions(+), 41 deletions(-) diff --git a/src/hooks/useConciergeAskState.ts b/src/hooks/useConciergeAskState.ts index 0933d6b0430e..3dfdeb26f481 100644 --- a/src/hooks/useConciergeAskState.ts +++ b/src/hooks/useConciergeAskState.ts @@ -1,18 +1,18 @@ import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; -import {isCreatedAction, isCurrentUserPendingAddAction} from '@libs/ReportActionsUtils'; +import {isCreatedAction, isDeletedParentAction} from '@libs/ReportActionsUtils'; -import {useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; +import {useConciergeChatHistoryState} from '@pages/inbox/ConciergeChatHistoryContext'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; -import type {ReportActions} from '@src/types/onyx/ReportAction'; +import type {ReportAction, ReportActions} from '@src/types/onyx/ReportAction'; import type {OnyxEntry} from 'react-native-onyx'; import {useRoute} from '@react-navigation/native'; -import {isOpenChildTaskAction} from './useConciergeSidePanelReportActions'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useIsInSidePanel from './useIsInSidePanel'; import useOnyx from './useOnyx'; @@ -22,7 +22,7 @@ type ConciergeAskState = { /** Whether this is the Concierge chat in the Inbox */ isAskConciergeChat: boolean; - /** Whether the messages from before this session are shown */ + /** Whether the earlier conversation is shown */ isHistoryExpanded: boolean; /** Whether to show the `Ask me anything!` welcome screen in place of the messages */ @@ -35,39 +35,34 @@ type ConciergeAskState = { shouldLabelComposerAsNewQuestion: boolean; }; +/** An OPEN task assigned to the current user. Canceled tasks stay OPEN optimistically, so deleted parents are excluded. */ +function isOpenChildTaskAction(action: ReportAction, currentUserAccountID: number): boolean { + return ( + action.childType === CONST.REPORT.TYPE.TASK && + action.childManagerAccountID === currentUserAccountID && + action.childStateNum === CONST.REPORT.STATE_NUM.OPEN && + action.childStatusNum === CONST.REPORT.STATUS_NUM.OPEN && + !isDeletedParentAction(action) + ); +} + function useConciergeAskState(reportID: string | undefined): ConciergeAskState { const isInSidePanel = useIsInSidePanel(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const route = useRoute>(); - const {sessionStartTime, showFullHistory} = useConciergeSessionState(); + const {showFullHistory} = useConciergeChatHistoryState(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - const hasSessionActivitySelector = (actions: OnyxEntry) => { - if (!actions || !sessionStartTime) { - return false; - } - return Object.values(actions).some((action) => { - // The list pins a still-open task, so it stays on screen in every session. - if (isOpenChildTaskAction(action, currentUserAccountID)) { - return true; - } - - // An action that opens its own report, like a question's thread or a task, keeps the welcome both while sending and once sent. - // Otherwise reconnecting would switch this chat to a partial view that `Hide chat history` can't return to. - if (action.actorAccountID === currentUserAccountID && !!action.childReportID) { - return false; - } - return isCurrentUserPendingAddAction(action, currentUserAccountID) || (!isCreatedAction(action) && action.created >= sessionStartTime); - }); - }; - const [hasSessionActivity] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { - selector: hasSessionActivitySelector, + // A task the user still has to finish keeps the chat on screen. + const hasOpenTaskSelector = (actions: OnyxEntry) => !!actions && Object.values(actions).some((action) => isOpenChildTaskAction(action, currentUserAccountID)); + const [hasOpenTask] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + selector: hasOpenTaskSelector, }); // `View chat history` only appears once the user has messaged Concierge, so earlier messages without one stay on screen. const hasHistoryWithoutUserMessageSelector = (actions: OnyxEntry) => { - if (!actions || !sessionStartTime) { + if (!actions) { return false; } const allActions = Object.values(actions); @@ -76,8 +71,8 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { if (!allActions.some((action) => isCreatedAction(action))) { return false; } - const previousActions = allActions.filter((action) => !isCreatedAction(action) && action.created < sessionStartTime); - return previousActions.length > 0 && !previousActions.some((action) => action.actorAccountID === currentUserAccountID); + const messages = allActions.filter((action) => !isCreatedAction(action)); + return messages.length > 0 && !messages.some((action) => action.actorAccountID === currentUserAccountID); }; const [hasHistoryWithoutUserMessage] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { selector: hasHistoryWithoutUserMessageSelector, @@ -85,19 +80,17 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { const isAskConciergeChat = !!reportID && reportID === conciergeReportID && !isInSidePanel; - const hasSessionBoundary = isAskConciergeChat && !!sessionStartTime; - // A linked action opens the report at that message, so the earlier conversation is on screen either way. const isHistoryExpanded = showFullHistory || !!route.params?.reportActionID; - const shouldShowWelcome = hasSessionBoundary && !isHistoryExpanded && !hasSessionActivity && !hasHistoryWithoutUserMessage; + const shouldShowWelcome = isAskConciergeChat && !isHistoryExpanded && !hasOpenTask && !hasHistoryWithoutUserMessage; return { isAskConciergeChat, isHistoryExpanded, shouldShowWelcome, shouldCenterWelcomeWithComposer: shouldShowWelcome && !shouldUseNarrowLayout, - shouldLabelComposerAsNewQuestion: hasSessionBoundary && showFullHistory && !hasSessionActivity, + shouldLabelComposerAsNewQuestion: isAskConciergeChat && showFullHistory, }; } diff --git a/src/hooks/useShouldSuppressConciergeIndicators.tsx b/src/hooks/useShouldSuppressConciergeIndicators.tsx index 72ba49a35db4..753a1744df7c 100644 --- a/src/hooks/useShouldSuppressConciergeIndicators.tsx +++ b/src/hooks/useShouldSuppressConciergeIndicators.tsx @@ -1,7 +1,5 @@ import {isCreatedAction, isCurrentUserPendingAddAction} from '@libs/ReportActionsUtils'; -import {useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; - import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportActions} from '@src/types/onyx/ReportAction'; @@ -13,23 +11,23 @@ import useOnyx from './useOnyx'; import useSidePanelState from './useSidePanelState'; /** - * Returns true when thinking/typing indicators should be hidden. Two cases: - * 1. The Concierge welcome state — before any real message activity occurs - * in either the side panel or the main DM session. - * 2. The followup-list pending window — between trickle completion and the + * Returns true when thinking/typing indicators should be hidden. Three cases: + * 1. The main Concierge chat — Concierge answers each question in its own thread. + * 2. The Concierge side panel welcome state — before any real message activity + * occurs in the side panel session. + * 3. The followup-list pending window — between trickle completion and the * server reply with ``. */ function useShouldSuppressConciergeIndicators(reportID: string | undefined): boolean { const isInSidePanel = useIsInSidePanel(); const {sessionStartTime: sidePanelSessionStartTime} = useSidePanelState(); - const {sessionStartTime: mainDMSessionStartTime} = useConciergeSessionState(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [pendingFollowupList] = useOnyx(`${ONYXKEYS.COLLECTION.CONCIERGE_PENDING_FOLLOWUP_LIST}${reportID}`); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const isConciergeChat = reportID === conciergeReportID; - const sessionStartTime = isInSidePanel ? sidePanelSessionStartTime : mainDMSessionStartTime; + const sessionStartTime = isInSidePanel ? sidePanelSessionStartTime : null; // Derive the boolean inside the Onyx selector so the indicators re-render only when session activity // flips, not on every report-action change in the Concierge chat. From 502174392715070645d433b5db082c684f3503db Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 17:19:30 +0200 Subject: [PATCH 103/156] Scan Concierge actions once, and only in the main Concierge chat --- src/hooks/useConciergeAskState.ts | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/hooks/useConciergeAskState.ts b/src/hooks/useConciergeAskState.ts index 3dfdeb26f481..f9e6e41c2d3d 100644 --- a/src/hooks/useConciergeAskState.ts +++ b/src/hooks/useConciergeAskState.ts @@ -54,36 +54,37 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - // A task the user still has to finish keeps the chat on screen. - const hasOpenTaskSelector = (actions: OnyxEntry) => !!actions && Object.values(actions).some((action) => isOpenChildTaskAction(action, currentUserAccountID)); - const [hasOpenTask] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { - selector: hasOpenTaskSelector, - }); + const isAskConciergeChat = !!reportID && reportID === conciergeReportID && !isInSidePanel; - // `View chat history` only appears once the user has messaged Concierge, so earlier messages without one stay on screen. - const hasHistoryWithoutUserMessageSelector = (actions: OnyxEntry) => { - if (!actions) { + // Other reports skip the scan. + const shouldKeepMessagesOnScreenSelector = (actions: OnyxEntry) => { + if (!isAskConciergeChat || !actions) { return false; } const allActions = Object.values(actions); + // A task the user still has to finish stays on screen. + if (allActions.some((action) => isOpenChildTaskAction(action, currentUserAccountID))) { + return true; + } + // Until the chat's first action is loaded, the user's earlier messages may be on an older page. if (!allActions.some((action) => isCreatedAction(action))) { return false; } + + // `View chat history` only appears once the user has messaged Concierge, so earlier messages without one stay on screen. const messages = allActions.filter((action) => !isCreatedAction(action)); return messages.length > 0 && !messages.some((action) => action.actorAccountID === currentUserAccountID); }; - const [hasHistoryWithoutUserMessage] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { - selector: hasHistoryWithoutUserMessageSelector, + const [shouldKeepMessagesOnScreen] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + selector: shouldKeepMessagesOnScreenSelector, }); - const isAskConciergeChat = !!reportID && reportID === conciergeReportID && !isInSidePanel; - // A linked action opens the report at that message, so the earlier conversation is on screen either way. const isHistoryExpanded = showFullHistory || !!route.params?.reportActionID; - const shouldShowWelcome = isAskConciergeChat && !isHistoryExpanded && !hasOpenTask && !hasHistoryWithoutUserMessage; + const shouldShowWelcome = isAskConciergeChat && !isHistoryExpanded && !shouldKeepMessagesOnScreen; return { isAskConciergeChat, From cfa81b4cae1225fbab178f547c32860436e6740d Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 17:20:16 +0200 Subject: [PATCH 104/156] Update Concierge tests for the removed session --- tests/unit/ConciergeChatHistoryToggleTest.tsx | 6 +- tests/unit/ConciergeSessionContextTest.tsx | 33 -------- .../unit/hooks/useConciergeAskState.test.tsx | 84 +++++++++++-------- ...eShouldSuppressConciergeIndicators.test.ts | 39 +++++++-- 4 files changed, 83 insertions(+), 79 deletions(-) delete mode 100644 tests/unit/ConciergeSessionContextTest.tsx diff --git a/tests/unit/ConciergeChatHistoryToggleTest.tsx b/tests/unit/ConciergeChatHistoryToggleTest.tsx index c657b97314af..0ed2cc00fa2d 100644 --- a/tests/unit/ConciergeChatHistoryToggleTest.tsx +++ b/tests/unit/ConciergeChatHistoryToggleTest.tsx @@ -26,9 +26,9 @@ jest.mock('@hooks/useLazyAsset', () => ({ const mockSetShowFullHistory = jest.fn(); -jest.mock('@pages/inbox/ConciergeSessionContext', () => ({ - useConciergeSessionState: () => ({sessionStartTime: '2024-06-01 12:00:00.000', showFullHistory: false, hadMessagesAtSessionStart: false}), - useConciergeSessionActions: () => ({setShowFullHistory: mockSetShowFullHistory}), +jest.mock('@pages/inbox/ConciergeChatHistoryContext', () => ({ + useConciergeChatHistoryState: () => ({showFullHistory: false}), + useConciergeChatHistoryActions: () => ({setShowFullHistory: mockSetShowFullHistory}), })); jest.mock('@react-navigation/native', () => ({ diff --git a/tests/unit/ConciergeSessionContextTest.tsx b/tests/unit/ConciergeSessionContextTest.tsx deleted file mode 100644 index 96a02570aaf4..000000000000 --- a/tests/unit/ConciergeSessionContextTest.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import {act, renderHook} from '@testing-library/react-native'; - -import {ConciergeSessionProvider, useConciergeSessionActions, useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; - -import ONYXKEYS from '@src/ONYXKEYS'; - -import React from 'react'; -import Onyx from 'react-native-onyx'; - -const UNREAD_BOUNDARY = '2024-06-01 12:00:00.000'; - -const wrapper = ({children}: {children: React.ReactNode}) => {children}; - -describe('ConciergeSessionProvider', () => { - beforeAll(() => { - Onyx.init({keys: ONYXKEYS}); - }); - - it('keeps the New chat boundary when the unread anchor resolves again', () => { - // Given a session that starts at the unread boundary - const {result} = renderHook(() => ({...useConciergeSessionState(), ...useConciergeSessionActions()}), {wrapper}); - act(() => result.current.startSession(UNREAD_BOUNDARY)); - expect(result.current.sessionStartTime).toBe(UNREAD_BOUNDARY); - - // When the user presses New chat and the unread boundary resolves again - act(() => result.current.resetSession()); - const boundaryAfterReset = result.current.sessionStartTime; - act(() => result.current.startSession(UNREAD_BOUNDARY)); - - // Then the session keeps the New chat boundary, so earlier messages stay hidden - expect(result.current.sessionStartTime).toBe(boundaryAfterReset); - }); -}); diff --git a/tests/unit/hooks/useConciergeAskState.test.tsx b/tests/unit/hooks/useConciergeAskState.test.tsx index 4951916ce5e2..28d2fda13486 100644 --- a/tests/unit/hooks/useConciergeAskState.test.tsx +++ b/tests/unit/hooks/useConciergeAskState.test.tsx @@ -16,15 +16,14 @@ import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; const wrapper = ({children}: {children: React.ReactNode}) => {children}; const CONCIERGE_REPORT_ID = '1'; -const SESSION_START = '2024-06-01 12:00:00.000'; jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ __esModule: true, default: jest.fn(() => ({accountID: 100})), })); -jest.mock('@pages/inbox/ConciergeSessionContext', () => ({ - useConciergeSessionState: () => ({sessionStartTime: SESSION_START, showFullHistory: false, hadMessagesAtSessionStart: false}), +jest.mock('@pages/inbox/ConciergeChatHistoryContext', () => ({ + useConciergeChatHistoryState: () => ({showFullHistory: false}), })); let mockRouteParams: {reportActionID?: string} = {}; @@ -45,7 +44,7 @@ describe('useConciergeAskState', () => { mockRouteParams = {}; }); - it('shows the empty state when the session has no activity', async () => { + it('shows the empty state once the user has messaged Concierge', async () => { const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); expect(result.current.isAskConciergeChat).toBe(true); @@ -77,7 +76,7 @@ describe('useConciergeAskState', () => { // Then the conversation stays on screen, because `View chat history` can't bring it back expect(result.current.shouldShowWelcome).toBe(false); - // When the user has messaged Concierge before this session + // When the user has messaged Concierge before const userMessage = { reportActionID: '6', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, @@ -94,54 +93,71 @@ describe('useConciergeAskState', () => { await waitForBatchedUpdates(); }); - it('keeps the empty state for a question that opens its own thread, while sending and once sent', async () => { - // Given a question from the current user that opened its own thread and is still sending (for example, offline) + it('keeps the empty state when a question arrives from any device', async () => { + // Given the user messaged Concierge before, and the whole chat is loaded + const createdAction = { + reportActionID: '7', + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + created: '2024-06-01 10:00:00.000', + }; + const earlierMessage = { + reportActionID: '6', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: 100, + created: '2024-06-01 11:00:00.000', + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, { + [createdAction.reportActionID]: createdAction, + [earlierMessage.reportActionID]: earlierMessage, + }); + await waitForBatchedUpdates(); + const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); + expect(result.current.shouldShowWelcome).toBe(true); + + // When a question sent from another device arrives before its thread is linked const question = { reportActionID: '3', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, actorAccountID: 100, created: '2024-06-01 12:05:00.000', - childReportID: '4', - childType: CONST.REPORT.TYPE.CHAT, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }; - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[question.reportActionID]: question}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[question.reportActionID]: question}); await waitForBatchedUpdates(); - // When the Concierge chat renders - const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); - - // Then the welcome stays, because the question is answered in its thread + // Then the welcome stays, so this device doesn't flicker expect(result.current.shouldShowWelcome).toBe(true); - // When the question is sent (for example, after reconnecting) - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[question.reportActionID]: {pendingAction: null}}); + // When its thread is linked + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[question.reportActionID]: {childReportID: '4', childType: CONST.REPORT.TYPE.CHAT}}); await waitForBatchedUpdates(); - // Then the welcome still stays, so the collapsed chat doesn't switch to a partial list on its own + // Then the welcome still stays, because the question is answered in its thread expect(result.current.shouldShowWelcome).toBe(true); - // When the thread fails to be created, which clears the question's child thread - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[question.reportActionID]: {childReportID: null, childType: ''}}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, null); await waitForBatchedUpdates(); + }); - // Then the question counts as activity, so its error stays visible in this chat - expect(result.current.shouldShowWelcome).toBe(false); - - // When the current user creates a task in the Concierge chat - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[question.reportActionID]: {childReportID: '5', childType: CONST.REPORT.TYPE.TASK}}); + it('keeps an open task assigned to the user on screen', async () => { + // Given an open task assigned to the current user in the Concierge chat + const task = { + reportActionID: '3', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: 100, + created: '2024-06-01 12:05:00.000', + childReportID: '5', + childType: CONST.REPORT.TYPE.TASK, + childManagerAccountID: 100, + childStateNum: CONST.REPORT.STATE_NUM.OPEN, + childStatusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[task.reportActionID]: task}); await waitForBatchedUpdates(); - // Then the welcome stays, like it does for a question - expect(result.current.shouldShowWelcome).toBe(true); - - // When that task is assigned to the current user and still open - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, { - [question.reportActionID]: {childManagerAccountID: 100, childStateNum: CONST.REPORT.STATE_NUM.OPEN, childStatusNum: CONST.REPORT.STATUS_NUM.OPEN}, - }); - await waitForBatchedUpdates(); + // When the Concierge chat renders + const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); - // Then the welcome stands down, so the open task stays pinned on screen + // Then the welcome stands down, so the task the user still has to finish stays visible expect(result.current.shouldShowWelcome).toBe(false); await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, null); diff --git a/tests/unit/hooks/useShouldSuppressConciergeIndicators.test.ts b/tests/unit/hooks/useShouldSuppressConciergeIndicators.test.ts index 260e233fa049..280431aaac2e 100644 --- a/tests/unit/hooks/useShouldSuppressConciergeIndicators.test.ts +++ b/tests/unit/hooks/useShouldSuppressConciergeIndicators.test.ts @@ -1,9 +1,9 @@ import {renderHook, waitFor} from '@testing-library/react-native'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useIsInSidePanel from '@hooks/useIsInSidePanel'; import useShouldSuppressConciergeIndicators from '@hooks/useShouldSuppressConciergeIndicators'; - -import {useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; +import useSidePanelState from '@hooks/useSidePanelState'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -14,10 +14,12 @@ import Onyx from 'react-native-onyx'; import createRandomReportAction from '../../utils/collections/reportActions'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; -jest.mock('@pages/inbox/ConciergeSessionContext'); jest.mock('@hooks/useCurrentUserPersonalDetails'); +jest.mock('@hooks/useIsInSidePanel'); +jest.mock('@hooks/useSidePanelState'); -const mockUseConciergeSessionState = jest.mocked(useConciergeSessionState); +const mockUseIsInSidePanel = jest.mocked(useIsInSidePanel); +const mockUseSidePanelState = jest.mocked(useSidePanelState); const mockUseCurrentUserPersonalDetails = jest.mocked(useCurrentUserPersonalDetails); const REPORT_ID = '1'; @@ -47,8 +49,9 @@ describe('useShouldSuppressConciergeIndicators', () => { beforeEach(async () => { await Onyx.clear(); await waitForBatchedUpdates(); - // Safe defaults: no active session, so the existing followup-list tests behave as before. - mockUseConciergeSessionState.mockReturnValue({sessionStartTime: null, showFullHistory: false, hadMessagesAtSessionStart: false}); + // Safe defaults: outside the side panel with no active session, so the followup-list tests only exercise the flag. + mockUseIsInSidePanel.mockReturnValue(false); + mockUseSidePanelState.mockReturnValue({sessionStartTime: null} as ReturnType); mockUseCurrentUserPersonalDetails.mockReturnValue({accountID: CURRENT_USER_ACCOUNT_ID}); }); @@ -121,10 +124,28 @@ describe('useShouldSuppressConciergeIndicators', () => { }); }); - describe('session activity (Concierge welcome state)', () => { + it('suppresses indicators in the main Concierge chat even with messages in it', async () => { + // Given the main Concierge chat with a Concierge reply in it + await Onyx.set(ONYXKEYS.CONCIERGE_REPORT_ID, REPORT_ID); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, { + [REPORT_ACTION_ID]: buildAction({actorAccountID: OTHER_ACCOUNT_ID, created: '2026-06-29 10:05:00.000'}), + }); + await waitForBatchedUpdates(); + + // When the hook evaluates suppression outside the side panel + const {result} = renderHook(() => useShouldSuppressConciergeIndicators(REPORT_ID)); + + // Then the indicators stay hidden, because Concierge answers each question in its own thread + await waitFor(() => { + expect(result.current).toBe(true); + }); + }); + + describe('side panel session activity (Concierge welcome state)', () => { beforeEach(() => { - // An active session for the main Concierge DM. - mockUseConciergeSessionState.mockReturnValue({sessionStartTime: SESSION_START_TIME, showFullHistory: false, hadMessagesAtSessionStart: false}); + // An active side panel session. + mockUseIsInSidePanel.mockReturnValue(true); + mockUseSidePanelState.mockReturnValue({sessionStartTime: SESSION_START_TIME} as ReturnType); }); it('suppresses indicators in a Concierge chat with no session activity', async () => { From ec221b30ccfb2511e55d8e567a0ec74cd7a57c48 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 17:20:35 +0200 Subject: [PATCH 105/156] Drop main Concierge chat cases from side panel and skeleton tests --- tests/unit/ReportActionsListContextTest.ts | 2 - ...useConciergeSidePanelReportActions.test.ts | 144 +----------------- 2 files changed, 7 insertions(+), 139 deletions(-) diff --git a/tests/unit/ReportActionsListContextTest.ts b/tests/unit/ReportActionsListContextTest.ts index d23ca6dc7830..b1402c8b730e 100644 --- a/tests/unit/ReportActionsListContextTest.ts +++ b/tests/unit/ReportActionsListContextTest.ts @@ -50,8 +50,6 @@ function createReadinessSignals(overrides: Partial { - const SESSION_START = toDBTime(CLIENT_OPEN_MS); - - /** Builds the parent action of a task, carrying the child* fields the backend stamps on it. */ - function buildTaskAction( - reportActionID: string, - stateNum: ReportAction['childStateNum'], - statusNum: ReportAction['childStatusNum'], - overrides: Partial = {}, - ): ReportAction { - return buildAction(reportActionID, { - actorAccountID: CONCIERGE_ACCOUNT_ID, - created: toDBTime(CLIENT_OPEN_MS - 3_400_000), - childType: CONST.REPORT.TYPE.TASK, - childReportID: `task-${reportActionID}`, - childManagerAccountID: CURRENT_USER_ACCOUNT_ID, - childStateNum: stateNum, - childStatusNum: statusNum, - ...overrides, - }); - } - - /** @param hasSessionActivity when false, nothing has happened yet this session — the welcome state. */ - function renderMainDM(taskAction: ReportAction, hasSessionActivity = true) { - const createdAction = buildAction('10', {actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, created: toDBTime(CLIENT_OPEN_MS - 7_200_000)}); - const preSessionUser = buildAction('11', {created: toDBTime(CLIENT_OPEN_MS - 3_600_000)}); - const preSessionConcierge = buildAction('12', {actorAccountID: CONCIERGE_ACCOUNT_ID, created: toDBTime(CLIENT_OPEN_MS - 3_500_000)}); - const inSessionUser = buildAction('20', {created: toDBTime(CLIENT_OPEN_MS + 1000)}); - const reportActions = [createdAction, preSessionUser, preSessionConcierge, taskAction, ...(hasSessionActivity ? [inSessionUser] : [])]; - - const report: Report = {...createRandomReport(Number(REPORT_ID)), reportID: REPORT_ID, lastReadTime: SESSION_START, hasOutstandingChildTask: true}; - - return renderHook(() => - useConciergeSidePanelReportActions({ - report, - reportActions, - visibleReportActions: reportActions, - isConciergeHiddenHistory: true, - hasUserSentMessage: hasSessionActivity, - hasOlderActions: false, - sessionStartTime: SESSION_START, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - greetingText: 'Hi there, how can I help?', - loadOlderChats: jest.fn(), - isConciergeMainDM: true, - showFullHistory: false, - hadMessagesAtSessionStart: hasSessionActivity, - }), - ); - } - - it('keeps a still-open child task visible while the rest of the read history stays hidden', () => { - // Given a pre-session task that the user has not completed yet - const {result} = renderMainDM(buildTaskAction('30', CONST.REPORT.STATE_NUM.OPEN, CONST.REPORT.STATUS_NUM.OPEN)); - - // When the main DM filters the session's actions - const visibleIDs = result.current.filteredReportActions.map((action) => action.reportActionID); - - // Then the task stays pinned, the read history stays hidden, and "Show history" is still offered. - expect(visibleIDs).toContain('30'); - expect(visibleIDs).toContain('20'); - expect(visibleIDs).not.toContain('11'); - expect(visibleIDs).not.toContain('12'); - expect(result.current.showFullHistory).toBe(false); - expect(result.current.hasPreviousMessages).toBe(true); - }); - - it('keeps a still-open child task visible in the fresh-session welcome state', () => { - // Given the DM is opened with nothing sent yet — the welcome state, which used to drop the pinned task - const {result} = renderMainDM(buildTaskAction('30', CONST.REPORT.STATE_NUM.OPEN, CONST.REPORT.STATUS_NUM.OPEN), false); - - // When the main DM filters the session's actions - const visibleIDs = result.current.filteredReportActions.map((action) => action.reportActionID); - - // Then the welcome state stands down so the task renders, while the read history stays hidden. - expect(result.current.showConciergeSidePanelWelcome).toBe(false); - expect(visibleIDs).toContain('30'); - expect(visibleIDs).not.toContain('11'); - expect(visibleIDs).not.toContain('12'); - }); - - it('does not pin an open child task assigned to someone else', () => { - // Given an open task assigned to another account - const {result} = renderMainDM(buildTaskAction('30', CONST.REPORT.STATE_NUM.OPEN, CONST.REPORT.STATUS_NUM.OPEN, {childManagerAccountID: CONCIERGE_ACCOUNT_ID}), false); - - // When the main DM filters the session's actions - const visibleIDs = result.current.filteredReportActions.map((action) => action.reportActionID); - - // Then it is ordinary read history: not pinned, and the welcome state still applies. - expect(visibleIDs).not.toContain('30'); - expect(result.current.showConciergeSidePanelWelcome).toBe(true); - }); - - it('does not pin a canceled task whose parent action is still optimistically OPEN', () => { - // Given a canceled task, which `deleteTask` leaves at OPEN until the server responds - const canceledTask = buildTaskAction('30', CONST.REPORT.STATE_NUM.OPEN, CONST.REPORT.STATUS_NUM.OPEN, { - message: [{type: 'COMMENT', html: '', text: '', isDeletedParentAction: true}], - childVisibleActionCount: 1, - }); - const {result} = renderMainDM(canceledTask, false); - - // When the main DM filters the session's actions - const visibleIDs = result.current.filteredReportActions.map((action) => action.reportActionID); - - // Then the canceled task is not pinned and the welcome state still applies. - expect(visibleIDs).not.toContain('30'); - expect(result.current.showConciergeSidePanelWelcome).toBe(true); - }); - - it('hides a completed child task along with the rest of the read history', () => { - // Given a pre-session task that has already been completed - const {result} = renderMainDM(buildTaskAction('30', CONST.REPORT.STATE_NUM.APPROVED, CONST.REPORT.STATUS_NUM.APPROVED)); - - // When the main DM filters the session's actions - const visibleIDs = result.current.filteredReportActions.map((action) => action.reportActionID); - - // Then it is treated as ordinary read history and collapses behind "Show history". - expect(visibleIDs).not.toContain('30'); - expect(visibleIDs).not.toContain('11'); - expect(visibleIDs).toContain('20'); - }); -}); - -describe('useConciergeSidePanelReportActions (main DM sent-message retention)', () => { +describe('useConciergeSidePanelReportActions (sent-message retention)', () => { const SESSION_START = toDBTime(CLIENT_OPEN_MS); const createdAction = buildAction('10', {actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, created: toDBTime(CLIENT_OPEN_MS - 7_200_000)}); @@ -261,7 +137,7 @@ describe('useConciergeSidePanelReportActions (main DM sent-message retention)', const preSessionConcierge = buildAction('12', {actorAccountID: CONCIERGE_ACCOUNT_ID, created: toDBTime(CLIENT_OPEN_MS - 3_500_000)}); /** Mirrors how `useReportActionsVisibility` calls the hook: the side panel drives its own local state. */ - function buildProps(sentMessage: ReportAction, hasUserSentMessage: boolean, isConciergeMainDM: boolean) { + function buildProps(sentMessage: ReportAction, hasUserSentMessage: boolean) { const reportActions = [createdAction, preSessionUser, preSessionConcierge, sentMessage]; const report: Report = {...createRandomReport(Number(REPORT_ID)), reportID: REPORT_ID, lastReadTime: SESSION_START}; @@ -276,9 +152,6 @@ describe('useConciergeSidePanelReportActions (main DM sent-message retention)', currentUserAccountID: CURRENT_USER_ACCOUNT_ID, greetingText: 'Hi there, how can I help?', loadOlderChats: jest.fn(), - isConciergeMainDM, - showFullHistory: isConciergeMainDM ? false : undefined, - hadMessagesAtSessionStart: isConciergeMainDM ? false : undefined, }; } @@ -286,23 +159,20 @@ describe('useConciergeSidePanelReportActions (main DM sent-message retention)', const pendingMessage = () => buildAction('20', {created: toDBTime(CLIENT_OPEN_MS + 500), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}); const restampedMessage = () => buildAction('20', {created: toDBTime(CLIENT_OPEN_MS - 3000), pendingAction: undefined}); - function renderSurface(isConciergeMainDM: boolean) { + function renderSurface() { const {result, rerender} = renderHook((props: Parameters[0]) => useConciergeSidePanelReportActions(props), { - initialProps: buildProps(pendingMessage(), true, isConciergeMainDM), + initialProps: buildProps(pendingMessage(), true), }); return { result, // The server response clears the pending flag and re-stamps `created`, which also flips `hasUserSentMessage`. - landServerResponse: () => rerender(buildProps(restampedMessage(), false, isConciergeMainDM)), + landServerResponse: () => rerender(buildProps(restampedMessage(), false)), }; } - it.each([ - ['main DM', true], - ['side panel', false], - ])('keeps the message the user just sent in the %s when the server stamps it below the session boundary', (_surface, isConciergeMainDM) => { + it('keeps the message the user just sent when the server stamps it below the session boundary', () => { // Given the user's message is still pending, so it is visible on the strength of its pending state - const {result, landServerResponse} = renderSurface(isConciergeMainDM); + const {result, landServerResponse} = renderSurface(); expect(result.current.filteredReportActions.map((action) => action.reportActionID)).toContain('20'); // When the server response lands and re-stamps `created` behind the boundary From a7ea87c871231293da9523acf54baec667407949 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 17:21:10 +0200 Subject: [PATCH 106/156] Update the list UI test for the main Concierge chat without a session --- tests/ui/ReportActionsListTest.tsx | 314 +++-------------------------- 1 file changed, 23 insertions(+), 291 deletions(-) diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index c203fd67b3d7..1bef124ebeed 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -17,8 +17,8 @@ import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViol import DateUtils from '@libs/DateUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import {useConciergeChatHistoryActions, useConciergeChatHistoryState} from '@pages/inbox/ConciergeChatHistoryContext'; import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; -import {useConciergeSessionActions, useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; import ReportActionsList from '@pages/inbox/report/ReportActionsList'; import CONST from '@src/CONST'; @@ -61,9 +61,9 @@ jest.mock('@hooks/useParentReportAction', () => jest.fn()); jest.mock('@hooks/useIsInSidePanel', () => jest.fn()); jest.mock('@hooks/useSidePanelState', () => jest.fn()); jest.mock('@hooks/useReportTransactionsCollection', () => jest.fn()); -jest.mock('@pages/inbox/ConciergeSessionContext', () => ({ - useConciergeSessionState: jest.fn(), - useConciergeSessionActions: jest.fn(), +jest.mock('@pages/inbox/ConciergeChatHistoryContext', () => ({ + useConciergeChatHistoryState: jest.fn(), + useConciergeChatHistoryActions: jest.fn(), })); jest.mock('@pages/inbox/ConciergeDraftContext', () => ({ useConciergeDraft: jest.fn(), @@ -82,8 +82,8 @@ const mockUseSidePanelState = useSidePanelState as jest.MockedFunction; const mockUseConciergeDraft = useConciergeDraft as jest.MockedFunction; const mockUseConciergeDraftActions = useConciergeDraftActions as jest.MockedFunction; -const mockUseConciergeSessionState = useConciergeSessionState as jest.MockedFunction; -const mockUseConciergeSessionActions = useConciergeSessionActions as jest.MockedFunction; +const mockUseConciergeChatHistoryState = useConciergeChatHistoryState as jest.MockedFunction; +const mockUseConciergeChatHistoryActions = useConciergeChatHistoryActions as jest.MockedFunction; function getMockReportLoadingState(selector: unknown, hasOnceLoadedReportActions = true) { return selector === reportActionsListLoadingStateSelector ? {hasOnceLoadedReportActions, isLoadingInitialReportActions: false} : undefined; @@ -329,8 +329,8 @@ describe('ReportActionsList (body)', () => { dispatchLocalDraftEvent: jest.fn(), revealDraftFromReportAction: jest.fn(), }); - mockUseConciergeSessionState.mockReturnValue({sessionStartTime: null, showFullHistory: false, hadMessagesAtSessionStart: false}); - mockUseConciergeSessionActions.mockReturnValue({startSession: jest.fn(), resetSession: jest.fn(), setShowFullHistory: jest.fn(), setHadMessagesAtSessionStart: jest.fn()}); + mockUseConciergeChatHistoryState.mockReturnValue({showFullHistory: false}); + mockUseConciergeChatHistoryActions.mockReturnValue({setShowFullHistory: jest.fn()}); mockUseOnyx.mockImplementation(getMockOnyxValue); }); @@ -1059,10 +1059,9 @@ describe('ReportActionsList (body)', () => { }); }); - describe('Concierge Main DM Hidden History', () => { + describe('Concierge Main DM', () => { const CONCIERGE_REPORT_ID = '123'; const CURRENT_USER_ACCOUNT_ID = 100; - const SESSION_START = '2024-06-01 12:00:00.000'; const oldReportActions: OnyxTypes.ReportAction[] = [ { @@ -1103,37 +1102,13 @@ describe('ReportActionsList (body)', () => { }, ]; - // The session filter keys off the child* fields the backend stamps on a task's parent action. - const buildTaskAction = ( - reportActionID: string, - created: string, - stateNum: OnyxTypes.ReportAction['childStateNum'], - statusNum: OnyxTypes.ReportAction['childStatusNum'], - ): OnyxTypes.ReportAction => ({ - reportActionID, - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - created, - actorAccountID: 456, - message: [{type: 'COMMENT', html: 'Take a test drive', text: 'Take a test drive'}], - originalMessage: {}, - childType: CONST.REPORT.TYPE.TASK, - childReportID: `task-${reportActionID}`, - childManagerAccountID: CURRENT_USER_ACCOUNT_ID, - childStateNum: stateNum, - childStatusNum: statusNum, - shouldShow: true, - person: [{type: 'TEXT', style: 'strong', text: 'Concierge'}], - pendingAction: null, - errors: {}, - }); - - const setupMainDMConciergeMocks = (sessionStartTime: string | null = SESSION_START, showFullHistory = false, hasOnceLoadedReportActions = true, hasOutstandingChildTask = false) => { + const setupMainDMConciergeMocks = (showFullHistory = false, hasOnceLoadedReportActions = true) => { jest.spyOn(ReportActionsUtils, 'shouldReportActionBeVisible').mockReturnValue(true); mockUseNetwork.mockReturnValue({isOffline: false}); mockUseIsInSidePanel.mockReturnValue(false); mockUseSidePanelState.mockReturnValue(defaultSidePanelState); - mockUseConciergeSessionState.mockReturnValue({sessionStartTime, showFullHistory, hadMessagesAtSessionStart: false}); - mockUseConciergeSessionActions.mockReturnValue({startSession: jest.fn(), resetSession: jest.fn(), setShowFullHistory: jest.fn(), setHadMessagesAtSessionStart: jest.fn()}); + mockUseConciergeChatHistoryState.mockReturnValue({showFullHistory}); + mockUseConciergeChatHistoryActions.mockReturnValue({setShowFullHistory: jest.fn()}); mockUseOnyx.mockImplementation((key: string, options) => { if (key === ONYXKEYS.CONCIERGE_REPORT_ID) { @@ -1152,7 +1127,7 @@ describe('ReportActionsList (body)', () => { return [[], {status: 'loaded'}]; } if (key === `${ONYXKEYS.COLLECTION.REPORT}${CONCIERGE_REPORT_ID}`) { - return [{...mockReport, reportID: CONCIERGE_REPORT_ID, hasOutstandingChildTask}, {status: 'loaded'}]; + return [{...mockReport, reportID: CONCIERGE_REPORT_ID}, {status: 'loaded'}]; } if (key.includes('report')) { return [undefined, {status: 'loaded'}]; @@ -1161,284 +1136,41 @@ describe('ReportActionsList (body)', () => { }); }; - it('should show greeting and filter old messages when session is active with prior user messages', () => { - setupMainDMConciergeMocks(); - - mockUsePaginatedReportActions.mockReturnValue({ - ...defaultPaginatedReportActionsResult, - reportActions: oldReportActions, - hasOlderActions: false, - }); - - renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - - expect(mockInvertedFlashList).toHaveBeenCalled(); - const passedActions = getCapturedVisibleActions(); - expect(passedActions?.some((a) => a.reportActionID === CONST.CONCIERGE_GREETING_ACTION_ID)).toBe(true); - expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(false); - expect(passedActions?.some((a) => a.reportActionID === 'old-concierge-msg')).toBe(false); - }); - - it('should keep read history hidden when the Concierge DM still has an outstanding child task', () => { - // Regression guard: an incomplete onboarding task used to force `showFullHistory` on permanently, - // which both un-hid the history and suppressed the "Show history" button. - setupMainDMConciergeMocks(SESSION_START, false, true, true); - - mockUsePaginatedReportActions.mockReturnValue({ - ...defaultPaginatedReportActionsResult, - reportActions: oldReportActions, - hasOlderActions: false, - }); - - renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - - expect(mockInvertedFlashList).toHaveBeenCalled(); - const passedActions = getCapturedVisibleActions(); - expect(passedActions?.some((a) => a.reportActionID === CONST.CONCIERGE_GREETING_ACTION_ID)).toBe(true); - expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(false); - expect(passedActions?.some((a) => a.reportActionID === 'old-concierge-msg')).toBe(false); - }); - - it('should keep a still-open child task visible while the rest of the read history stays hidden', () => { - setupMainDMConciergeMocks(SESSION_START, false, true, true); - - // An in-session message keeps the list out of welcome mode, so the session filter actually runs. - const newUserMessage: OnyxTypes.ReportAction = { - reportActionID: 'new-user-msg', - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - created: '2024-06-01 12:05:00.000', - actorAccountID: CURRENT_USER_ACCOUNT_ID, - message: [{type: 'COMMENT', html: 'Hello', text: 'Hello'}], - originalMessage: {}, - shouldShow: true, - person: [{type: 'TEXT', style: 'strong', text: 'Test User'}], - pendingAction: null, - errors: {}, - }; - - mockUsePaginatedReportActions.mockReturnValue({ - ...defaultPaginatedReportActionsResult, - reportActions: [ - ...oldReportActions, - buildTaskAction('open-task', '2023-06-15 10:02:00.000', CONST.REPORT.STATE_NUM.OPEN, CONST.REPORT.STATUS_NUM.OPEN), - buildTaskAction('completed-task', '2023-06-15 10:03:00.000', CONST.REPORT.STATE_NUM.APPROVED, CONST.REPORT.STATUS_NUM.APPROVED), - newUserMessage, - ], - hasOlderActions: false, - }); - - renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - - expect(mockInvertedFlashList).toHaveBeenCalled(); - const passedActions = getCapturedVisibleActions(); - expect(passedActions?.some((a) => a.reportActionID === 'new-user-msg')).toBe(true); - expect(passedActions?.some((a) => a.reportActionID === 'open-task')).toBe(true); - expect(passedActions?.some((a) => a.reportActionID === 'completed-task')).toBe(false); - expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(false); - expect(passedActions?.some((a) => a.reportActionID === 'old-concierge-msg')).toBe(false); - }); - - it('should keep a still-open child task visible in the fresh-session welcome view', () => { - // Opening the DM without sending anything puts the list in welcome mode, which returns early before the - // session filter runs. An open task must still survive that path, or it stays hidden until "Show history". - setupMainDMConciergeMocks(SESSION_START, false, true, true); - - mockUsePaginatedReportActions.mockReturnValue({ - ...defaultPaginatedReportActionsResult, - reportActions: [...oldReportActions, buildTaskAction('open-task', '2023-06-15 10:02:00.000', CONST.REPORT.STATE_NUM.OPEN, CONST.REPORT.STATUS_NUM.OPEN)], - hasOlderActions: false, - }); - - renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - - expect(mockInvertedFlashList).toHaveBeenCalled(); - const passedActions = getCapturedVisibleActions(); - expect(passedActions?.some((a) => a.reportActionID === 'open-task')).toBe(true); - expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(false); - expect(passedActions?.some((a) => a.reportActionID === 'old-concierge-msg')).toBe(false); - }); - - it('should show all actions when showFullHistory is true', () => { - setupMainDMConciergeMocks(SESSION_START, true); - + it('should show the whole conversation without a greeting once the history is expanded', () => { + // Given the user expanded the main Concierge chat history + setupMainDMConciergeMocks(true); mockUsePaginatedReportActions.mockReturnValue({ ...defaultPaginatedReportActionsResult, reportActions: oldReportActions, hasOlderActions: false, }); + // When the list renders renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); + // Then every earlier message shows, because only the side panel collapses its history expect(mockInvertedFlashList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); + expect(passedActions?.some((a) => a.reportActionID === CONST.CONCIERGE_GREETING_ACTION_ID)).toBe(false); expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(true); expect(passedActions?.some((a) => a.reportActionID === 'old-concierge-msg')).toBe(true); }); - it('should show all actions unfiltered when user sends a message in current session', () => { - setupMainDMConciergeMocks(); - - const actionsWithNewMsg: OnyxTypes.ReportAction[] = [ - ...oldReportActions, - { - reportActionID: 'new-user-msg', - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - created: '2024-06-01 12:05:00.000', - actorAccountID: CURRENT_USER_ACCOUNT_ID, - message: [{type: 'COMMENT', html: 'Hello', text: 'Hello'}], - originalMessage: {}, - shouldShow: true, - person: [{type: 'TEXT', style: 'strong', text: 'Test User'}], - pendingAction: null, - errors: {}, - }, - ]; - - mockUsePaginatedReportActions.mockReturnValue({ - ...defaultPaginatedReportActionsResult, - reportActions: actionsWithNewMsg, - hasOlderActions: false, - }); - - renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - - expect(mockInvertedFlashList).toHaveBeenCalled(); - const passedActions = getCapturedVisibleActions(); - // After user sends a message, the greeting stays visible alongside session actions - expect(passedActions?.some((a) => a.reportActionID === CONST.CONCIERGE_GREETING_ACTION_ID)).toBe(true); - expect(passedActions?.some((a) => a.reportActionID === 'new-user-msg')).toBe(true); - }); - - it('should not show old messages when sessionStartTime is null (session not started)', () => { - setupMainDMConciergeMocks(null); - - mockUsePaginatedReportActions.mockReturnValue({ - ...defaultPaginatedReportActionsResult, - reportActions: oldReportActions, - hasOlderActions: false, - }); - - renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - - expect(mockInvertedFlashList).toHaveBeenCalled(); - const passedActions = getCapturedVisibleActions(); - // With no session, old messages should not be shown - expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(false); - expect(passedActions?.some((a) => a.reportActionID === 'old-concierge-msg')).toBe(false); - }); - - it('should pass through onboarding messages for a new user with no prior messages', () => { - setupMainDMConciergeMocks(); - - // Only onboarding messages from Concierge, no user messages before session - const onboardingActions: OnyxTypes.ReportAction[] = [ - { - reportActionID: 'created-1', - actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, - created: '2024-06-01 11:59:00.000', - actorAccountID: 123, - message: [{type: 'COMMENT', html: '', text: ''}], - originalMessage: {}, - shouldShow: true, - person: [{type: 'TEXT', style: 'strong', text: 'System'}], - pendingAction: null, - errors: {}, - }, - { - reportActionID: 'onboarding-msg', - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - created: '2024-06-01 11:59:30.000', - actorAccountID: 456, - message: [{type: 'COMMENT', html: 'Welcome to Expensify!', text: 'Welcome to Expensify!'}], - originalMessage: {}, - shouldShow: true, - person: [{type: 'TEXT', style: 'strong', text: 'Concierge'}], - pendingAction: null, - errors: {}, - }, - ]; - - mockUsePaginatedReportActions.mockReturnValue({ - ...defaultPaginatedReportActionsResult, - reportActions: onboardingActions, - hasOlderActions: false, - }); - - renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - - expect(mockInvertedFlashList).toHaveBeenCalled(); - const passedActions = getCapturedVisibleActions(); - // New user with no prior messages — onboarding messages pass through (no filtering) - expect(passedActions?.some((a) => a.reportActionID === 'onboarding-msg')).toBe(true); - }); - - it('should hide pre-session messages when nothing matches the session and there is no created action', () => { - setupMainDMConciergeMocks(); - - mockUsePaginatedReportActions.mockReturnValue({ - ...defaultPaginatedReportActionsResult, - reportActions: oldReportActions.filter((action) => action.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED), - hasOlderActions: true, - }); - - renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - - expect(mockInvertedFlashList).toHaveBeenCalled(); - const passedActions = getCapturedVisibleActions(); - expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(false); - expect(passedActions?.some((a) => a.reportActionID === 'old-concierge-msg')).toBe(false); - }); - - it('should call startSession on mount for main DM concierge', () => { - const mockStartSession = jest.fn(); - mockUseConciergeSessionActions.mockReturnValue({startSession: mockStartSession, resetSession: jest.fn(), setShowFullHistory: jest.fn(), setHadMessagesAtSessionStart: jest.fn()}); - setupMainDMConciergeMocks(); - mockUseConciergeSessionActions.mockReturnValue({startSession: mockStartSession, resetSession: jest.fn(), setShowFullHistory: jest.fn(), setHadMessagesAtSessionStart: jest.fn()}); - - mockUsePaginatedReportActions.mockReturnValue({ - ...defaultPaginatedReportActionsResult, - reportActions: oldReportActions, - hasOlderActions: false, - }); - - renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - - expect(mockStartSession).toHaveBeenCalled(); - }); - it('should render cached actions without a skeleton on refresh when hasOnceLoadedReportActions resets but actions are cached', () => { - // Simulates a page refresh: hasOnceLoadedReportActions is RAM-only and resets to false, - // but report actions persist in Onyx cache. We should render them immediately (production behavior). - setupMainDMConciergeMocks(SESSION_START, false, false); - + // Given a page refresh: hasOnceLoadedReportActions is RAM-only and resets to false, but report actions persist in Onyx + setupMainDMConciergeMocks(true, false); mockUsePaginatedReportActions.mockReturnValue({ ...defaultPaginatedReportActionsResult, reportActions: oldReportActions, hasOlderActions: false, }); + // When the list renders renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); + // Then the cached actions render right away expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); expect(mockInvertedFlashList).toHaveBeenCalled(); }); - - it('should show a skeleton on a cold load when hasOnceLoadedReportActions is false and there are no cached actions', () => { - setupMainDMConciergeMocks(SESSION_START, false, false); - - mockUsePaginatedReportActions.mockReturnValue({ - ...defaultPaginatedReportActionsResult, - reportActions: [], - hasOlderActions: false, - }); - - renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - - expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); - // The concierge-hidden-history skeleton shows while the report is otherwise "ready"; the content - // is still not mounted, so the UI-close hooks never run here either. - expect(mockUseMarkAsRead).not.toHaveBeenCalled(); - expect(mockUseReportActionsScroll).not.toHaveBeenCalled(); - }); }); }); From 559af842bdd8c9e9a19e6a1e4142c5459e160972 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 17:43:21 +0200 Subject: [PATCH 107/156] Show the Concierge welcome even with an open task or a linked message --- src/hooks/useConciergeAskState.ts | 34 +++---------------- src/hooks/useReportActionsListModel.ts | 2 +- .../report/ConciergeChatHistoryToggle.tsx | 11 +----- tests/unit/ConciergeChatHistoryToggleTest.tsx | 14 +------- .../unit/hooks/useConciergeAskState.test.tsx | 26 +++++++++----- 5 files changed, 24 insertions(+), 63 deletions(-) diff --git a/src/hooks/useConciergeAskState.ts b/src/hooks/useConciergeAskState.ts index f9e6e41c2d3d..edc7a427d6f0 100644 --- a/src/hooks/useConciergeAskState.ts +++ b/src/hooks/useConciergeAskState.ts @@ -1,18 +1,12 @@ -import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; -import {isCreatedAction, isDeletedParentAction} from '@libs/ReportActionsUtils'; +import {isCreatedAction} from '@libs/ReportActionsUtils'; import {useConciergeChatHistoryState} from '@pages/inbox/ConciergeChatHistoryContext'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type SCREENS from '@src/SCREENS'; -import type {ReportAction, ReportActions} from '@src/types/onyx/ReportAction'; +import type {ReportActions} from '@src/types/onyx/ReportAction'; import type {OnyxEntry} from 'react-native-onyx'; -import {useRoute} from '@react-navigation/native'; - import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useIsInSidePanel from './useIsInSidePanel'; import useOnyx from './useOnyx'; @@ -35,21 +29,9 @@ type ConciergeAskState = { shouldLabelComposerAsNewQuestion: boolean; }; -/** An OPEN task assigned to the current user. Canceled tasks stay OPEN optimistically, so deleted parents are excluded. */ -function isOpenChildTaskAction(action: ReportAction, currentUserAccountID: number): boolean { - return ( - action.childType === CONST.REPORT.TYPE.TASK && - action.childManagerAccountID === currentUserAccountID && - action.childStateNum === CONST.REPORT.STATE_NUM.OPEN && - action.childStatusNum === CONST.REPORT.STATUS_NUM.OPEN && - !isDeletedParentAction(action) - ); -} - function useConciergeAskState(reportID: string | undefined): ConciergeAskState { const isInSidePanel = useIsInSidePanel(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const route = useRoute>(); const {showFullHistory} = useConciergeChatHistoryState(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); @@ -63,11 +45,6 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { } const allActions = Object.values(actions); - // A task the user still has to finish stays on screen. - if (allActions.some((action) => isOpenChildTaskAction(action, currentUserAccountID))) { - return true; - } - // Until the chat's first action is loaded, the user's earlier messages may be on an older page. if (!allActions.some((action) => isCreatedAction(action))) { return false; @@ -81,14 +58,11 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { selector: shouldKeepMessagesOnScreenSelector, }); - // A linked action opens the report at that message, so the earlier conversation is on screen either way. - const isHistoryExpanded = showFullHistory || !!route.params?.reportActionID; - - const shouldShowWelcome = isAskConciergeChat && !isHistoryExpanded && !shouldKeepMessagesOnScreen; + const shouldShowWelcome = isAskConciergeChat && !showFullHistory && !shouldKeepMessagesOnScreen; return { isAskConciergeChat, - isHistoryExpanded, + isHistoryExpanded: showFullHistory, shouldShowWelcome, shouldCenterWelcomeWithComposer: shouldShowWelcome && !shouldUseNarrowLayout, shouldLabelComposerAsNewQuestion: isAskConciergeChat && showFullHistory, diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index 861a7aba0918..48860e4a9096 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -91,7 +91,7 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea canPerformWriteAction, hasOlderActions, loadOlderChats, - conciergeShowFullHistory: conciergeShowFullHistory || !!reportActionIDFromRoute, + conciergeShowFullHistory, setConciergeShowFullHistory, }); diff --git a/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx b/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx index b0974e396cf8..f5d9623e18b1 100644 --- a/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx +++ b/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx @@ -5,15 +5,12 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import Navigation from '@libs/Navigation/Navigation'; - import {useConciergeChatHistoryActions} from '@pages/inbox/ConciergeChatHistoryContext'; import CONST from '@src/CONST'; import type {StyleProp, ViewStyle} from 'react-native'; -import {useNavigation, useRoute} from '@react-navigation/native'; import React from 'react'; import {View} from 'react-native'; @@ -40,14 +37,8 @@ function ConciergeChatHistoryToggle({reportID, hasPreviousMessages, shouldShowFu const expensifyIcons = useMemoizedLazyExpensifyIcons(['UpArrow', 'DownArrow']); const {isAskConciergeChat} = useConciergeAskState(reportID); const {setShowFullHistory} = useConciergeChatHistoryActions(); - const route = useRoute(); - const navigation = useNavigation(); - const hideChatHistory = () => { - setShowFullHistory(false); - // Arriving from a thread opens the report at the parent message, which keeps the earlier conversation on screen. - Navigation.setParams({reportActionID: undefined}, route.key, navigation.getState()?.key); - }; + const hideChatHistory = () => setShowFullHistory(false); if (!isAskConciergeChat || !hasPreviousMessages) { return null; diff --git a/tests/unit/ConciergeChatHistoryToggleTest.tsx b/tests/unit/ConciergeChatHistoryToggleTest.tsx index 0ed2cc00fa2d..589da25cf5d4 100644 --- a/tests/unit/ConciergeChatHistoryToggleTest.tsx +++ b/tests/unit/ConciergeChatHistoryToggleTest.tsx @@ -5,16 +5,12 @@ import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {IsInSidePanelContext} from '@hooks/useIsInSidePanel'; -import Navigation from '@libs/Navigation/Navigation'; - import ConciergeChatHistoryToggle from '@pages/inbox/report/ConciergeChatHistoryToggle'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; -import type * as NativeNavigation from '@react-navigation/native'; - import React from 'react'; import Onyx from 'react-native-onyx'; @@ -31,12 +27,6 @@ jest.mock('@pages/inbox/ConciergeChatHistoryContext', () => ({ useConciergeChatHistoryActions: () => ({setShowFullHistory: mockSetShowFullHistory}), })); -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useRoute: () => ({key: 'Report-test', name: 'Report', params: {}}), - useNavigation: () => ({getState: () => ({key: 'ReportsSplitNavigator-test'})}), -})); - const CONCIERGE_REPORT_ID = '1'; const renderToggle = (overrides: {hasPreviousMessages?: boolean; shouldShowFullHistory?: boolean} = {}) => { @@ -60,7 +50,6 @@ const renderToggle = (overrides: {hasPreviousMessages?: boolean; shouldShowFullH describe('ConciergeChatHistoryToggle', () => { beforeAll(async () => { - jest.spyOn(Navigation, 'setParams').mockImplementation(() => {}); await IntlStore.load(CONST.LOCALES.EN); await Onyx.set(ONYXKEYS.CONCIERGE_REPORT_ID, CONCIERGE_REPORT_ID); await waitForBatchedUpdates(); @@ -90,9 +79,8 @@ describe('ConciergeChatHistoryToggle', () => { // When the user presses the toggle fireEvent.press(screen.getByRole('button')); - // Then the history collapses and the linked message is cleared, so the chat goes back to its welcome state + // Then the history collapses, so the chat goes back to its welcome state expect(mockSetShowFullHistory).toHaveBeenCalledWith(false); - expect(Navigation.setParams).toHaveBeenCalledWith({reportActionID: undefined}, 'Report-test', 'ReportsSplitNavigator-test'); expect(onShowPreviousMessages).not.toHaveBeenCalled(); }); diff --git a/tests/unit/hooks/useConciergeAskState.test.tsx b/tests/unit/hooks/useConciergeAskState.test.tsx index 28d2fda13486..1c58658a0e6c 100644 --- a/tests/unit/hooks/useConciergeAskState.test.tsx +++ b/tests/unit/hooks/useConciergeAskState.test.tsx @@ -138,8 +138,13 @@ describe('useConciergeAskState', () => { await waitForBatchedUpdates(); }); - it('keeps an open task assigned to the user on screen', async () => { - // Given an open task assigned to the current user in the Concierge chat + it('keeps the empty state when the chat has an open task assigned to the user', async () => { + // Given the whole chat is loaded and has an open task assigned to the current user + const createdAction = { + reportActionID: '7', + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + created: '2024-06-01 10:00:00.000', + }; const task = { reportActionID: '3', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, @@ -151,29 +156,32 @@ describe('useConciergeAskState', () => { childStateNum: CONST.REPORT.STATE_NUM.OPEN, childStatusNum: CONST.REPORT.STATUS_NUM.OPEN, }; - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[task.reportActionID]: task}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, { + [createdAction.reportActionID]: createdAction, + [task.reportActionID]: task, + }); await waitForBatchedUpdates(); // When the Concierge chat renders const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); - // Then the welcome stands down, so the task the user still has to finish stays visible - expect(result.current.shouldShowWelcome).toBe(false); + // Then the welcome shows, and the task stays behind `View chat history` + expect(result.current.shouldShowWelcome).toBe(true); await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, null); await waitForBatchedUpdates(); }); - it('shows the earlier conversation when the report opens at a linked action', () => { + it('keeps the empty state when the report opens at a linked action', () => { // Given a route that opens the Concierge chat at a linked message mockRouteParams = {reportActionID: '2'}; // When the Concierge chat renders const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); - // Then the earlier conversation shows, because the linked message sits inside it - expect(result.current.isHistoryExpanded).toBe(true); - expect(result.current.shouldShowWelcome).toBe(false); + // Then the welcome shows, because only `View chat history` expands the earlier conversation + expect(result.current.isHistoryExpanded).toBe(false); + expect(result.current.shouldShowWelcome).toBe(true); }); it('stays off outside the Concierge report', () => { From 151db4963c6c989112641672facc22759d397d2b Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 17:44:48 +0200 Subject: [PATCH 108/156] Close the open-report and send-message spans from the Concierge welcome --- src/CONST/index.ts | 1 + src/pages/inbox/report/AskConciergeWelcome.tsx | 14 +++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index dd73a18a45f4..1918901a717c 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2645,6 +2645,7 @@ const CONST = { SKELETON_GUARD_LOADING: 'skeleton_guard_loading', SKELETON_GUARD_DERIVED_TIMING: 'skeleton_guard_derived_timing', INBOX_TAB_DEFER: 'inbox_tab_defer', + CONCIERGE_WELCOME: 'concierge_welcome', }, /** Follow-up action after expense submit (action-based; used as submit_follow_up_action in span). */ SUBMIT_FOLLOW_UP_ACTION: { diff --git a/src/pages/inbox/report/AskConciergeWelcome.tsx b/src/pages/inbox/report/AskConciergeWelcome.tsx index bcb93fe6ab97..36f10fc5ac6b 100644 --- a/src/pages/inbox/report/AskConciergeWelcome.tsx +++ b/src/pages/inbox/report/AskConciergeWelcome.tsx @@ -1,8 +1,15 @@ import ScrollView from '@components/ScrollView'; +import useCancelSendMessageSpanOnSkeleton from '@hooks/useCancelSendMessageSpanOnSkeleton'; import useConciergeAskState from '@hooks/useConciergeAskState'; +import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + import React from 'react'; import AskConciergeEmptyState from './AskConciergeEmptyState'; @@ -12,7 +19,7 @@ type AskConciergeWelcomeProps = { /** The ID of the report being displayed */ reportID: string; - /** Whether there are messages from before this session */ + /** Whether the chat has earlier messages to show */ hasPreviousMessages: boolean; /** Called when the user presses `View chat history` */ @@ -22,6 +29,10 @@ type AskConciergeWelcomeProps = { function AskConciergeWelcome({reportID, hasPreviousMessages, onShowPreviousMessages}: AskConciergeWelcomeProps) { const styles = useThemeStyles(); const {shouldCenterWelcomeWithComposer} = useConciergeAskState(reportID); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + + // A message sent from the welcome screen never renders in this chat, so its span is canceled. + useCancelSendMessageSpanOnSkeleton(reportID, CONST.TELEMETRY.CANCELED_BY_SKELETON.CONCIERGE_WELCOME); return ( markOpenReportEnd(reportID, report, {warm: true})} > Date: Tue, 20 Oct 2026 17:46:30 +0200 Subject: [PATCH 109/156] Keep every synced attachment when saving an edit made mid-upload --- src/libs/ReportUtils.ts | 19 ++++++++++--------- tests/unit/ReportUtilsTest.ts | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 0ca62aba70e6..4f6e37b86532 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -7572,8 +7572,9 @@ function getUploadingAttachmentHtml(file?: FileObject, attachmentID?: string): s } /** - * Saving a draft captured mid-upload would persist a local URI only the author's device can resolve, so swap it - * for the synced attachment, or drop it while the upload is pending because the queued Add re-attaches the file. + * Saving a draft captured mid-upload would persist local URIs only the author's device can resolve, so swap each one + * for the synced attachment in the same position, or drop them while the upload is pending because the queued Add + * re-attaches the files. */ function replaceLocalAttachmentReferences(draftMarkdown: string, currentCommentHtml: string | undefined, reportActionID: string): string { const localAttachmentReferenceRegex = /\n*(?:!?\[[^\]]*\]\((?:blob:|file:|content:)[^)]*\)|!\((?:blob:|file:|content:)[^)]*\))/g; @@ -7588,23 +7589,23 @@ function replaceLocalAttachmentReferences(draftMarkdown: string, currentCommentH } const attachmentTags = currentCommentHtml.match(/]*>|]*>[\s\S]*?<\/video>|]*data-expensify-source="[^"]*"[^>]*>[\s\S]*?<\/a>/gi) ?? []; - const syncedAttachmentTag = attachmentTags.find((tag) => { + const syncedAttachmentTags = attachmentTags.filter((tag) => { const source = tag.match(CONST.REGEX.ATTACHMENT.ATTACHMENT_SOURCE)?.at(2) ?? ''; return source.match(CONST.REGEX.ATTACHMENT.ATTACHMENT_SOURCE_ID)?.at(1) === reportActionID; }); - if (!syncedAttachmentTag) { + if (syncedAttachmentTags.length === 0) { return draftMarkdown; } - const syncedAttachmentMarkdown = Parser.htmlToMarkdown(syncedAttachmentTag); - let isReplaced = false; + let localReferenceIndex = 0; localAttachmentReferenceRegex.lastIndex = 0; return draftMarkdown.replace(localAttachmentReferenceRegex, (match) => { - if (isReplaced) { + const syncedAttachmentTag = syncedAttachmentTags.at(localReferenceIndex); + localReferenceIndex++; + if (!syncedAttachmentTag) { return ''; } - isReplaced = true; - return `${match.match(/^\n*/)?.at(0) ?? ''}${syncedAttachmentMarkdown}`; + return `${match.match(/^\n*/)?.at(0) ?? ''}${Parser.htmlToMarkdown(syncedAttachmentTag)}`; }); } diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index da5636721c0d..b82e752a312f 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -7764,6 +7764,20 @@ describe('ReportUtils', () => { ); }); + it('swaps each local reference for the synced attachment in the same position', () => { + // Given a message with two images that synced after the user started editing + const syncedTwoImagesHtml = `Hello

photo.jpgreceipt.jpg`; + const draft = 'Hello edited\n\n!(blob:https://dev.new.expensify.com:8082/uuid-1)\n\n!(blob:https://dev.new.expensify.com:8082/uuid-2)'; + + // When the edit is saved + const savedDraft = replaceLocalAttachmentReferences(draft, syncedTwoImagesHtml, reportActionID); + + // Then both images stay in the message, so the edit doesn't delete the second one + expect(savedDraft).toBe( + `Hello edited\n\n![photo.jpg](https://www.expensify.com/chat-attachments/${reportActionID}/w_abc.jpg)\n\n![receipt.jpg](https://www.expensify.com/chat-attachments/${reportActionID}/w_def.jpg)`, + ); + }); + it('swaps a document reference for the synced attachment link', () => { const syncedDocHtml = `Hello

file.doc`; const draft = 'Hello edited\n\n[file.doc](blob:https://dev.new.expensify.com:8082/uuid-1)'; From 5e04cc8d5016034e1f3ec93c4157d3acb0fa42ce Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 17:46:31 +0200 Subject: [PATCH 110/156] Drop the unused main chat flag and old session wording --- src/hooks/useReportActionsVisibility.ts | 2 -- src/pages/inbox/report/ConciergeChatHistoryToggle.tsx | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/hooks/useReportActionsVisibility.ts b/src/hooks/useReportActionsVisibility.ts index 8df204f71246..44f86a7c59ac 100644 --- a/src/hooks/useReportActionsVisibility.ts +++ b/src/hooks/useReportActionsVisibility.ts @@ -32,7 +32,6 @@ type UseReportActionsVisibilityResult = { sortedReportActions: ReportAction[]; sortedVisibleReportActions: ReportAction[]; isConciergeSidePanel: boolean; - isConciergeMainDM: boolean; isConciergeHiddenHistory: boolean; showConciergeSidePanelWelcome: boolean; showFullHistory: boolean; @@ -127,7 +126,6 @@ function useReportActionsVisibility({ sortedReportActions: filteredReportActions, sortedVisibleReportActions: filteredVisibleActions, isConciergeSidePanel, - isConciergeMainDM, isConciergeHiddenHistory, showConciergeSidePanelWelcome, showFullHistory: isConciergeMainDM ? !!conciergeShowFullHistory : showFullHistory, diff --git a/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx b/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx index f5d9623e18b1..f326eb833ac5 100644 --- a/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx +++ b/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx @@ -18,7 +18,7 @@ type ConciergeChatHistoryToggleProps = { /** The ID of the report being displayed */ reportID: string; - /** Whether there are messages hidden before the session start */ + /** Whether the chat has earlier messages to show */ hasPreviousMessages: boolean; /** Whether the earlier conversation is currently shown */ From 668274329d9f412afe47d838e1c4002ddff7d7b8 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 17:47:00 +0200 Subject: [PATCH 111/156] Remove unnecessary comments --- src/components/Search/SearchRouter/useAskConcierge.tsx | 1 - src/hooks/useConciergeAskState.ts | 4 +--- src/hooks/useShouldSuppressConciergeIndicators.tsx | 8 ++------ src/libs/ReportUtils.ts | 6 +----- src/libs/actions/Report/index.ts | 1 - src/libs/actions/RequestConflictUtils.ts | 1 - src/pages/inbox/ConciergeChatHistoryContext.tsx | 6 +----- src/pages/inbox/report/AskConciergeWelcome.tsx | 2 -- src/pages/inbox/report/ShowPreviousMessagesButton.tsx | 1 - 9 files changed, 5 insertions(+), 25 deletions(-) diff --git a/src/components/Search/SearchRouter/useAskConcierge.tsx b/src/components/Search/SearchRouter/useAskConcierge.tsx index 75a9b608a4f4..0e0b0a217fc3 100644 --- a/src/components/Search/SearchRouter/useAskConcierge.tsx +++ b/src/components/Search/SearchRouter/useAskConcierge.tsx @@ -28,7 +28,6 @@ function useAskConcierge({forceConcierge = false}: {forceConcierge?: boolean} = const {openConciergeAnywhere, isInSidePanel} = useOpenConciergeAnywhere(); const [sidePanelReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(sidePanelReportID)}`); - // Each new question gets its own Concierge thread, so a side panel showing an earlier thread still targets Concierge. const isSidePanelOnConciergeThread = !!conciergeReportID && sidePanelReport?.parentReportID === conciergeReportID; const targetReportID = !forceConcierge && isInSidePanel && sidePanelReportID && !isSidePanelOnConciergeThread ? sidePanelReportID : conciergeReportID; const [targetReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(targetReportID)}`); diff --git a/src/hooks/useConciergeAskState.ts b/src/hooks/useConciergeAskState.ts index edc7a427d6f0..ab366ea070c4 100644 --- a/src/hooks/useConciergeAskState.ts +++ b/src/hooks/useConciergeAskState.ts @@ -38,19 +38,17 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { const isAskConciergeChat = !!reportID && reportID === conciergeReportID && !isInSidePanel; - // Other reports skip the scan. const shouldKeepMessagesOnScreenSelector = (actions: OnyxEntry) => { if (!isAskConciergeChat || !actions) { return false; } const allActions = Object.values(actions); - // Until the chat's first action is loaded, the user's earlier messages may be on an older page. + // The user's earlier messages may be on an older page. if (!allActions.some((action) => isCreatedAction(action))) { return false; } - // `View chat history` only appears once the user has messaged Concierge, so earlier messages without one stay on screen. const messages = allActions.filter((action) => !isCreatedAction(action)); return messages.length > 0 && !messages.some((action) => action.actorAccountID === currentUserAccountID); }; diff --git a/src/hooks/useShouldSuppressConciergeIndicators.tsx b/src/hooks/useShouldSuppressConciergeIndicators.tsx index 753a1744df7c..7c58fcd9bbf1 100644 --- a/src/hooks/useShouldSuppressConciergeIndicators.tsx +++ b/src/hooks/useShouldSuppressConciergeIndicators.tsx @@ -11,12 +11,8 @@ import useOnyx from './useOnyx'; import useSidePanelState from './useSidePanelState'; /** - * Returns true when thinking/typing indicators should be hidden. Three cases: - * 1. The main Concierge chat — Concierge answers each question in its own thread. - * 2. The Concierge side panel welcome state — before any real message activity - * occurs in the side panel session. - * 3. The followup-list pending window — between trickle completion and the - * server reply with ``. + * Returns true when thinking/typing indicators should be hidden: in the main Concierge chat, in the Concierge + * side panel before the session has any messages, and while a followup list is pending. */ function useShouldSuppressConciergeIndicators(reportID: string | undefined): boolean { const isInSidePanel = useIsInSidePanel(); diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 4f6e37b86532..e6f1ba8cc705 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -7571,11 +7571,7 @@ function getUploadingAttachmentHtml(file?: FileObject, attachmentID?: string): s return `${file.name}`; } -/** - * Saving a draft captured mid-upload would persist local URIs only the author's device can resolve, so swap each one - * for the synced attachment in the same position, or drop them while the upload is pending because the queued Add - * re-attaches the files. - */ +/** Swaps each local attachment URI in a draft for its synced attachment, or drops them while the upload is pending. */ function replaceLocalAttachmentReferences(draftMarkdown: string, currentCommentHtml: string | undefined, reportActionID: string): string { const localAttachmentReferenceRegex = /\n*(?:!?\[[^\]]*\]\((?:blob:|file:|content:)[^)]*\)|!\((?:blob:|file:|content:)[^)]*\))/g; if (!localAttachmentReferenceRegex.test(draftMarkdown)) { diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index b57a30b9ad3b..907f1299d0cc 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -1326,7 +1326,6 @@ function addAttachmentWithComment({ playSound(SOUNDS.DONE); }; - // Single attachment, or several in one message so Concierge answers them in one thread if (!Array.isArray(attachments) || conciergeThreadReportID) { const [file, ...additionalFiles] = Array.isArray(attachments) ? attachments : [attachments]; addActions({ diff --git a/src/libs/actions/RequestConflictUtils.ts b/src/libs/actions/RequestConflictUtils.ts index 29cbbf104f72..951e5294285d 100644 --- a/src/libs/actions/RequestConflictUtils.ts +++ b/src/libs/actions/RequestConflictUtils.ts @@ -276,7 +276,6 @@ function resolveCommentDeletionConflicts(persistedRequests }; } -/** Reads the local URI of a queued file. Request data is untyped, so the shape is checked rather than cast. */ function getQueuedFileURI(file: unknown): string { return typeof file === 'object' && file !== null && 'uri' in file && typeof file.uri === 'string' ? file.uri : ''; } diff --git a/src/pages/inbox/ConciergeChatHistoryContext.tsx b/src/pages/inbox/ConciergeChatHistoryContext.tsx index ef755c61b537..b5b4711c645a 100644 --- a/src/pages/inbox/ConciergeChatHistoryContext.tsx +++ b/src/pages/inbox/ConciergeChatHistoryContext.tsx @@ -25,14 +25,10 @@ const ConciergeChatHistoryActionsContext = createContext session?.accountID; -/** - * Remembers whether the main Concierge chat history is expanded, so it stays expanded across the composer, the - * header and the list. Mounted at app level (App.tsx) so it never remounts. - */ function ConciergeChatHistoryProvider({children}: PropsWithChildren) { const [showFullHistory, setShowFullHistory] = useState(false); - // The provider never remounts, so collapse the history when the user switches accounts. + // Collapse the history when the user switches accounts. const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const [prevAccountID, setPrevAccountID] = useState(accountID); if (prevAccountID !== accountID) { diff --git a/src/pages/inbox/report/AskConciergeWelcome.tsx b/src/pages/inbox/report/AskConciergeWelcome.tsx index 36f10fc5ac6b..5504a346bba2 100644 --- a/src/pages/inbox/report/AskConciergeWelcome.tsx +++ b/src/pages/inbox/report/AskConciergeWelcome.tsx @@ -31,13 +31,11 @@ function AskConciergeWelcome({reportID, hasPreviousMessages, onShowPreviousMessa const {shouldCenterWelcomeWithComposer} = useConciergeAskState(reportID); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - // A message sent from the welcome screen never renders in this chat, so its span is canceled. useCancelSendMessageSpanOnSkeleton(reportID, CONST.TELEMETRY.CANCELED_BY_SKELETON.CONCIERGE_WELCOME); return ( markOpenReportEnd(reportID, report, {warm: true})} diff --git a/src/pages/inbox/report/ShowPreviousMessagesButton.tsx b/src/pages/inbox/report/ShowPreviousMessagesButton.tsx index 7f293cc77035..a29aefd45dfb 100644 --- a/src/pages/inbox/report/ShowPreviousMessagesButton.tsx +++ b/src/pages/inbox/report/ShowPreviousMessagesButton.tsx @@ -35,7 +35,6 @@ function ShowPreviousMessagesButton({reportID, actionType, hasPreviousMessages, const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const isConciergeChat = reportID === conciergeReportID; - // The main Concierge chat uses ConciergeChatHistoryToggle instead. if (!isConciergeChat || !isInSidePanel) { return null; } From 98716c61b520207ef3494767cf2295155fac1b93 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 17:48:37 +0200 Subject: [PATCH 112/156] Add tests for the Concierge welcome, New chat button, and new question label --- tests/ui/ReportActionsListTest.tsx | 35 ++++++++++++++++++++++++++ tests/ui/ReportFooterTest.tsx | 34 +++++++++++++++++++++++++ tests/ui/components/HeaderViewTest.tsx | 28 +++++++++++++++++++++ 3 files changed, 97 insertions(+) diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 1bef124ebeed..d38c06d033d4 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -1,5 +1,6 @@ import {render, screen, waitFor} from '@testing-library/react-native'; +import useConciergeAskState from '@hooks/useConciergeAskState'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; /* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ @@ -27,6 +28,7 @@ import {reportActionsListLoadingStateSelector} from '@src/selectors/ReportMetaDa import type * as OnyxTypes from '@src/types/onyx'; import type * as ReactNavigation from '@react-navigation/native'; +import type * as ReactNative from 'react-native'; import React from 'react'; import Onyx from 'react-native-onyx'; @@ -49,6 +51,14 @@ jest.mock('@react-navigation/native', () => { }); jest.mock('@hooks/useNetwork', () => jest.fn()); +jest.mock('@hooks/useConciergeAskState', () => ({ + __esModule: true, + default: jest.fn(jest.requireActual<{default: typeof useConciergeAskState}>('@hooks/useConciergeAskState').default), +})); +jest.mock('@pages/inbox/report/AskConciergeWelcome', () => { + const {View} = jest.requireActual('react-native'); + return () => ; +}); jest.mock('@hooks/useInFlightRequests', () => ({ ...jest.requireActual('@hooks/useInFlightRequests'), useIsReportLoadPending: jest.fn(), @@ -282,6 +292,7 @@ describe('ReportActionsList (body)', () => { beforeEach(() => { jest.clearAllMocks(); + jest.mocked(useConciergeAskState).mockImplementation(jest.requireActual<{default: typeof useConciergeAskState}>('@hooks/useConciergeAskState').default); mockUseIsReportLoadPending.mockReturnValue(false); mockUseCurrentUserPersonalDetails.mockReturnValue({ @@ -1136,6 +1147,30 @@ describe('ReportActionsList (body)', () => { }); }; + it('should show the welcome screen in place of the conversation by default', () => { + // Given the main Concierge chat with its history collapsed + setupMainDMConciergeMocks(); + jest.mocked(useConciergeAskState).mockReturnValue({ + isAskConciergeChat: true, + isHistoryExpanded: false, + shouldShowWelcome: true, + shouldCenterWelcomeWithComposer: true, + shouldLabelComposerAsNewQuestion: false, + }); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: oldReportActions, + hasOlderActions: false, + }); + + // When the list renders + renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); + + // Then the welcome screen shows and the earlier conversation stays behind `View chat history` + expect(screen.getByTestId('AskConciergeWelcome')).toBeTruthy(); + expect(mockInvertedFlashList).not.toHaveBeenCalled(); + }); + it('should show the whole conversation without a greeting once the history is expanded', () => { // Given the user expanded the main Concierge chat history setupMainDMConciergeMocks(true); diff --git a/tests/ui/ReportFooterTest.tsx b/tests/ui/ReportFooterTest.tsx index 3aa3c85779a0..e4b5b8fa25f1 100644 --- a/tests/ui/ReportFooterTest.tsx +++ b/tests/ui/ReportFooterTest.tsx @@ -4,6 +4,7 @@ import ComposeProviders from '@components/ComposeProviders'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import useConciergeAskState from '@hooks/useConciergeAskState'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import ReportFooter from '@pages/inbox/report/ReportFooter'; @@ -32,6 +33,16 @@ jest.mock('@react-navigation/native', () => { }); const mockedUseRoute = jest.mocked(useRoute); +jest.mock('@hooks/useConciergeAskState', () => ({ + __esModule: true, + default: jest.fn(jest.requireActual<{default: typeof useConciergeAskState}>('@hooks/useConciergeAskState').default), +})); + +jest.mock('@pages/inbox/report/ReportActionCompose/ReportActionCompose', () => { + const MockReportActionCompose = Object.assign(() => null, {EditOnly: () => null}); + return {__esModule: true, default: MockReportActionCompose}; +}); + const REPORT_ID = '1'; const OFFLINE_MESSAGE = 'You appear to be offline.'; @@ -92,6 +103,29 @@ describe('ReportFooter', () => { await Onyx.clear(); }); jest.clearAllMocks(); + jest.mocked(useConciergeAskState).mockImplementation(jest.requireActual<{default: typeof useConciergeAskState}>('@hooks/useConciergeAskState').default); + }); + + it('labels the composer `Ask a new question` once the Concierge chat history is expanded', async () => { + // Given the main Concierge chat with its history expanded + mockLayout({isSmallScreenWidth: false, isInNarrowPaneModal: false}); + jest.mocked(useConciergeAskState).mockReturnValue({ + isAskConciergeChat: true, + isHistoryExpanded: true, + shouldShowWelcome: false, + shouldCenterWelcomeWithComposer: false, + shouldLabelComposerAsNewQuestion: true, + }); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportID: REPORT_ID, type: CONST.REPORT.TYPE.CHAT}); + }); + + // When the footer renders + renderReportFooter(); + await waitForBatchedUpdatesWithAct(); + + // Then the composer is labeled, so the user knows a new message starts a new question + expect(screen.getByText('Ask a new question')).toBeOnTheScreen(); }); describe.each([ diff --git a/tests/ui/components/HeaderViewTest.tsx b/tests/ui/components/HeaderViewTest.tsx index 227411d07ab9..cf277887f7b5 100644 --- a/tests/ui/components/HeaderViewTest.tsx +++ b/tests/ui/components/HeaderViewTest.tsx @@ -8,6 +8,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import type Navigation from '@libs/Navigation/Navigation'; +import NavigationActions from '@libs/Navigation/Navigation'; import {buildOptimisticCreatedReportForUnapprovedAction} from '@libs/ReportUtils'; import HeaderView from '@pages/inbox/HeaderView'; @@ -124,6 +125,33 @@ describe('HeaderView', () => { await waitFor(() => expect(screen.getByTestId('DisplayNames')).toHaveTextContent(displayName)); }); + it('should go back to the Concierge chat when New chat is pressed in a Concierge thread', async () => { + // Given the header of a thread that Concierge opened for a question + const conciergeReportID = '10'; + const threadReport = {...createRandomReport(11, undefined), parentReportID: conciergeReportID}; + await Onyx.set(ONYXKEYS.CONCIERGE_REPORT_ID, conciergeReportID); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${threadReport.reportID}`, threadReport); + await waitForBatchedUpdates(); + const navigateSpy = jest.spyOn(NavigationActions, 'navigate').mockImplementation(() => {}); + + render( + + {}} + reportID={threadReport.reportID} + /> + , + ); + await waitForBatchedUpdatesWithAct(); + + // When the user presses New chat + fireEvent.press(screen.getByText(translateLocal('common.concierge.newChat'))); + + // Then the user is taken back to the Concierge chat to ask another question + expect(navigateSpy).toHaveBeenCalledWith(expect.stringContaining(`r/${conciergeReportID}`)); + navigateSpy.mockRestore(); + }); + it('should display join button', async () => { // Given an policy room header const report = { From 937aafc8c7ef2849fc2a0cce751613e2f8952bbf Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 19:55:45 +0200 Subject: [PATCH 113/156] Match edited attachments by attachment ID --- src/libs/ReportUtils.ts | 49 ++++++++++++++------- src/libs/actions/Report/index.ts | 21 +++++---- src/libs/actions/RequestConflictUtils.ts | 10 ++--- tests/unit/ReportUtilsTest.ts | 55 ++++++++++++++++-------- tests/unit/RequestConflictUtilsTest.ts | 6 +-- 5 files changed, 89 insertions(+), 52 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index e6f1ba8cc705..133b2ab92a55 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -7572,12 +7572,17 @@ function getUploadingAttachmentHtml(file?: FileObject, attachmentID?: string): s } /** Swaps each local attachment URI in a draft for its synced attachment, or drops them while the upload is pending. */ -function replaceLocalAttachmentReferences(draftMarkdown: string, currentCommentHtml: string | undefined, reportActionID: string): string { +function replaceLocalAttachmentReferences( + draftMarkdown: string, + currentCommentHtml: string | undefined, + reportActionID: string, + attachmentIDByLocalSource: Partial> = {}, +): string { const localAttachmentReferenceRegex = /\n*(?:!?\[[^\]]*\]\((?:blob:|file:|content:)[^)]*\)|!\((?:blob:|file:|content:)[^)]*\))/g; - if (!localAttachmentReferenceRegex.test(draftMarkdown)) { + const localReferences = draftMarkdown.match(localAttachmentReferenceRegex) ?? []; + if (localReferences.length === 0) { return draftMarkdown; } - localAttachmentReferenceRegex.lastIndex = 0; const isStillUploading = !currentCommentHtml || currentCommentHtml.includes(CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE); if (isStillUploading) { @@ -7593,10 +7598,18 @@ function replaceLocalAttachmentReferences(draftMarkdown: string, currentCommentH return draftMarkdown; } + // Without a known attachment ID, a reference can only be matched by position when none were removed. + const canMatchByPosition = localReferences.length === syncedAttachmentTags.length; let localReferenceIndex = 0; - localAttachmentReferenceRegex.lastIndex = 0; return draftMarkdown.replace(localAttachmentReferenceRegex, (match) => { - const syncedAttachmentTag = syncedAttachmentTags.at(localReferenceIndex); + const localSource = match.match(/\(([^)]*)\)$/)?.at(1) ?? ''; + const attachmentID = attachmentIDByLocalSource[localSource]; + let syncedAttachmentTag: string | undefined; + if (attachmentID) { + syncedAttachmentTag = syncedAttachmentTags.find((tag) => tag.match(CONST.REGEX.ATTACHMENT.ATTACHMENT_ID)?.at(2) === attachmentID); + } else if (canMatchByPosition) { + syncedAttachmentTag = syncedAttachmentTags.at(localReferenceIndex); + } localReferenceIndex++; if (!syncedAttachmentTag) { return ''; @@ -7613,10 +7626,9 @@ function buildEditedCommentWithAttachment(commentHtml: string, uploadingAttachme return [commentHtml, uploadingAttachmentHtml].filter(Boolean).join('

'); } -/** The still-uploading attachment tags, re-appended to an edit's optimistic message so they stay on screen. */ -function getUploadingAttachmentHtmlFromComment(currentCommentHtml: string | undefined, removedSources: string[] = []): string | undefined { +function getUploadingAttachmentTags(currentCommentHtml: string | undefined): string[] { if (!currentCommentHtml?.includes(CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE)) { - return undefined; + return []; } const attachmentTagRegex = new RegExp( `]*${CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE}="[^"]*"[^>]*>` + @@ -7624,18 +7636,25 @@ function getUploadingAttachmentHtmlFromComment(currentCommentHtml: string | unde `|]*${CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE}="[^"]*"[^>]*>[\\s\\S]*?`, 'gi', ); - const optimisticSourceRegex = new RegExp(`${CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE}="([^"]+)"`); - const keptTags = (currentCommentHtml.match(attachmentTagRegex) ?? []).filter((tag) => !removedSources.includes(tag.match(optimisticSourceRegex)?.at(1) ?? '')); + return currentCommentHtml.match(attachmentTagRegex) ?? []; +} + +/** The still-uploading attachment tags, re-appended to an edit's optimistic message so they stay on screen. */ +function getUploadingAttachmentHtmlFromComment(currentCommentHtml: string | undefined, removedAttachmentIDs: string[] = []): string | undefined { + const keptTags = getUploadingAttachmentTags(currentCommentHtml).filter((tag) => !removedAttachmentIDs.includes(tag.match(CONST.REGEX.ATTACHMENT.ATTACHMENT_ID)?.at(2) ?? '')); return keptTags.length > 0 ? keptTags.join('
') : undefined; } /** - * The local URIs of still-uploading attachments a draft dropped. Compared against the local URI, not the parsed HTML, + * The attachment IDs of still-uploading attachments a draft dropped. Compared against the local URI, not the parsed HTML, * because a kept reference stays plain markdown and never parses back into an attachment tag. */ -function getUploadingAttachmentSourcesRemovedFromDraft(draftMarkdown: string, currentCommentHtml: string | undefined): string[] { - const localSources = [...(currentCommentHtml ?? '').matchAll(new RegExp(`${CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE}="([^"]+)"`, 'g'))].map((match) => match[1]); - return localSources.filter((localSource) => !draftMarkdown.includes(localSource)); +function getUploadingAttachmentIDsRemovedFromDraft(draftMarkdown: string, currentCommentHtml: string | undefined): string[] { + const optimisticSourceRegex = new RegExp(`${CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE}="([^"]+)"`); + return getUploadingAttachmentTags(currentCommentHtml) + .filter((tag) => !draftMarkdown.includes(tag.match(optimisticSourceRegex)?.at(1) ?? '')) + .map((tag) => tag.match(CONST.REGEX.ATTACHMENT.ATTACHMENT_ID)?.at(2) ?? '') + .filter((attachmentID) => !!attachmentID); } function getReportDescription(report: OnyxEntry): string { @@ -15683,7 +15702,7 @@ export { canMergeReports, canModifyHoldStatus, replaceLocalAttachmentReferences, - getUploadingAttachmentSourcesRemovedFromDraft, + getUploadingAttachmentIDsRemovedFromDraft, getUploadingAttachmentHtmlFromComment, buildEditedCommentWithAttachment, parseMovedTransactionReportIDs, diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 907f1299d0cc..f10657f5d8da 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -176,7 +176,7 @@ import { isReportManuallyReimbursed, isReportNotFound, isSelfDM, - getUploadingAttachmentSourcesRemovedFromDraft, + getUploadingAttachmentIDsRemovedFromDraft, isValidReportIDFromPath, prepareOnboardingOnyxData, replaceLocalAttachmentReferences, @@ -603,6 +603,9 @@ Onyx.connectWithoutView({ callback: (value) => (allAttachments = value), }); +// Lets an edit saved after the upload finishes find which synced attachment each local URI in the draft became. +const attachmentIDByLocalSource: Partial> = {}; + let environment: EnvironmentType; getEnvironment().then((env) => { environment = env; @@ -943,9 +946,11 @@ function addActions({ lastActionCreated, }); attachmentAction = attachment.reportAction; - cacheAttachment({attachmentID, uri: file.uri ?? '', mimeType: file.type}); - for (const additionalAttachment of additionalAttachments) { - cacheAttachment({attachmentID: additionalAttachment.attachmentID, uri: additionalAttachment.file.uri ?? '', mimeType: additionalAttachment.file.type}); + for (const sentAttachment of [{file, attachmentID}, ...additionalAttachments]) { + cacheAttachment({attachmentID: sentAttachment.attachmentID, uri: sentAttachment.file.uri ?? '', mimeType: sentAttachment.file.type}); + if (sentAttachment.file.uri) { + attachmentIDByLocalSource[sentAttachment.file.uri] = sentAttachment.attachmentID; + } } } @@ -3735,8 +3740,8 @@ function editReportComment( // https://github.com/Expensify/App/issues/13221 const originalCommentHTML = ReportActionsUtils.getReportActionHtml(originalReportAction); const originalCommentMarkdown = Parser.htmlToMarkdown(originalCommentHTML ?? '', {reportIDToName}).trim(); - const removedAttachmentSources = getUploadingAttachmentSourcesRemovedFromDraft(textForNewComment, originalCommentHTML); - const draftForNewComment = replaceLocalAttachmentReferences(textForNewComment, originalCommentHTML, originalReportAction.reportActionID); + const removedAttachmentIDs = getUploadingAttachmentIDsRemovedFromDraft(textForNewComment, originalCommentHTML); + const draftForNewComment = replaceLocalAttachmentReferences(textForNewComment, originalCommentHTML, originalReportAction.reportActionID, attachmentIDByLocalSource); // Skip the Edit if draft is not changed if (originalCommentMarkdown === draftForNewComment) { @@ -3764,7 +3769,7 @@ function editReportComment( const originalMessage = ReportActionsUtils.getReportActionMessage(originalReportAction); // Optimistic message only: the sent copy is stripped, so without this the attachment vanishes until upload lands. - const uploadingAttachmentHtml = getUploadingAttachmentHtmlFromComment(originalCommentHTML, removedAttachmentSources); + const uploadingAttachmentHtml = getUploadingAttachmentHtmlFromComment(originalCommentHTML, removedAttachmentIDs); const optimisticHtml = buildEditedCommentWithAttachment(htmlForNewComment, uploadingAttachmentHtml); const optimisticText = uploadingAttachmentHtml ? Parser.htmlToText(optimisticHtml) : reportComment; @@ -3867,7 +3872,7 @@ function editReportComment( checkAndFixConflictingRequest: (persistedRequests) => { const addCommentIndex = persistedRequests.findIndex((request) => addNewMessageWithText.has(request.command) && request.data?.reportActionID === reportActionID); if (addCommentIndex > -1) { - return resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, addCommentIndex, removedAttachmentSources); + return resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, addCommentIndex, removedAttachmentIDs); } return resolveDuplicationConflictAction(persistedRequests as AnyRequest[], createUpdateCommentMatcher(reportActionID)); }, diff --git a/src/libs/actions/RequestConflictUtils.ts b/src/libs/actions/RequestConflictUtils.ts index 951e5294285d..74de6865be25 100644 --- a/src/libs/actions/RequestConflictUtils.ts +++ b/src/libs/actions/RequestConflictUtils.ts @@ -276,16 +276,12 @@ function resolveCommentDeletionConflicts(persistedRequests }; } -function getQueuedFileURI(file: unknown): string { - return typeof file === 'object' && file !== null && 'uri' in file && typeof file.uri === 'string' ? file.uri : ''; -} - function resolveEditCommentWithNewAddCommentRequest( persistedRequests: Array>, parameters: UpdateCommentParams, reportActionID: string, addCommentIndex: number, - removedAttachmentSources: string[] = [], + removedAttachmentIDs: string[] = [], ): ConflictActionData { const indicesToDelete: number[] = []; for (const [index, request] of persistedRequests.entries()) { @@ -301,13 +297,13 @@ function resolveEditCommentWithNewAddCommentRequest( currentAddComment.data = {...currentAddComment.data, ...parameters}; // The queued request keeps its own files, so without dropping them the server would re-add attachments the edit removed. - if (removedAttachmentSources.length > 0) { + if (removedAttachmentIDs.length > 0) { const data = currentAddComment.data; const fileKeys = ['file', ...Object.keys(data).filter((key) => CONST.ADDITIONAL_ATTACHMENT_FILE_KEY_REGEX.test(key))]; const attachmentIDs = typeof data.attachmentIDs === 'string' ? data.attachmentIDs.split(',') : [data.attachmentID]; const keptAttachments = fileKeys .map((key, index) => ({file: data[key], attachmentID: attachmentIDs.at(index)})) - .filter(({file}) => !!file && !removedAttachmentSources.includes(getQueuedFileURI(file))); + .filter(({file, attachmentID}) => !!file && !(typeof attachmentID === 'string' && removedAttachmentIDs.includes(attachmentID))); for (const key of fileKeys) { delete data[key]; diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index b82e752a312f..74a5c42ed8fa 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -207,7 +207,7 @@ import { isSortableColumnName, isThreadMemberProtectedByParentReport, isUnread, - getUploadingAttachmentSourcesRemovedFromDraft, + getUploadingAttachmentIDsRemovedFromDraft, isWorkspaceMemberLeavingWorkspaceRoom, parseMovedTransactionReportIDs, parseReportRouteParams, @@ -7736,7 +7736,7 @@ describe('ReportUtils', () => { describe('replaceLocalAttachmentReferences', () => { const reportActionID = '6076606438659303644'; const syncedImageHtml = `Hello

photo.jpg`; - const uploadingImageHtml = `Hello

photo.jpg`; + const uploadingImageHtml = `Hello

photo.jpg`; it('drops the local reference while the upload is still pending, since the queued send re-attaches the file', () => { const draft = 'Hello edited\n\n!(blob:https://dev.new.expensify.com:8082/uuid-1)'; @@ -7764,18 +7764,35 @@ describe('ReportUtils', () => { ); }); - it('swaps each local reference for the synced attachment in the same position', () => { - // Given a message with two images that synced after the user started editing - const syncedTwoImagesHtml = `Hello

photo.jpgreceipt.jpg`; - const draft = 'Hello edited\n\n!(blob:https://dev.new.expensify.com:8082/uuid-1)\n\n!(blob:https://dev.new.expensify.com:8082/uuid-2)'; + describe('several synced attachments', () => { + const firstSource = 'blob:https://dev.new.expensify.com:8082/uuid-1'; + const secondSource = 'blob:https://dev.new.expensify.com:8082/uuid-2'; + const syncedTwoImagesHtml = `Hello

photo.jpgreceipt.jpg`; + const attachmentIDByLocalSource = {[firstSource]: '11', [secondSource]: '12'}; - // When the edit is saved - const savedDraft = replaceLocalAttachmentReferences(draft, syncedTwoImagesHtml, reportActionID); + it('keeps every attachment the draft kept', () => { + // Given a message with two images that synced after the user started editing + const draft = `Hello edited\n\n!(${firstSource})\n\n!(${secondSource})`; - // Then both images stay in the message, so the edit doesn't delete the second one - expect(savedDraft).toBe( - `Hello edited\n\n![photo.jpg](https://www.expensify.com/chat-attachments/${reportActionID}/w_abc.jpg)\n\n![receipt.jpg](https://www.expensify.com/chat-attachments/${reportActionID}/w_def.jpg)`, - ); + // When the edit is saved + const savedDraft = replaceLocalAttachmentReferences(draft, syncedTwoImagesHtml, reportActionID, attachmentIDByLocalSource); + + // Then both images stay in the message + expect(savedDraft).toBe( + `Hello edited\n\n![photo.jpg](https://www.expensify.com/chat-attachments/${reportActionID}/w_abc.jpg)\n\n![receipt.jpg](https://www.expensify.com/chat-attachments/${reportActionID}/w_def.jpg)`, + ); + }); + + it('keeps the image the user kept when they deleted the other one', () => { + // Given the user deleted the first image while both were uploading + const draft = `Hello edited\n\n!(${secondSource})`; + + // When the edit is saved after the upload synced + const savedDraft = replaceLocalAttachmentReferences(draft, syncedTwoImagesHtml, reportActionID, attachmentIDByLocalSource); + + // Then the second image stays, not the first + expect(savedDraft).toBe(`Hello edited\n\n![receipt.jpg](https://www.expensify.com/chat-attachments/${reportActionID}/w_def.jpg)`); + }); }); it('swaps a document reference for the synced attachment link', () => { @@ -7800,17 +7817,17 @@ describe('ReportUtils', () => { }); it('reports the uploading attachment as removed once the draft drops its local reference', () => { - expect(getUploadingAttachmentSourcesRemovedFromDraft('Hello edited', uploadingImageHtml)).toEqual(['blob:https://dev.new.expensify.com:8082/uuid-1']); + expect(getUploadingAttachmentIDsRemovedFromDraft('Hello edited', uploadingImageHtml)).toEqual(['1']); }); it('does not report a removal while the draft still references the uploading attachment', () => { const draft = 'Hello edited\n\n!(blob:https://dev.new.expensify.com:8082/uuid-1)'; - expect(getUploadingAttachmentSourcesRemovedFromDraft(draft, uploadingImageHtml)).toEqual([]); + expect(getUploadingAttachmentIDsRemovedFromDraft(draft, uploadingImageHtml)).toEqual([]); }); it('does not report a removal when the attachment already synced', () => { - expect(getUploadingAttachmentSourcesRemovedFromDraft('Hello edited', syncedImageHtml)).toEqual([]); + expect(getUploadingAttachmentIDsRemovedFromDraft('Hello edited', syncedImageHtml)).toEqual([]); }); it('returns the uploading attachment tag so the optimistic message can keep showing it', () => { @@ -7823,19 +7840,19 @@ describe('ReportUtils', () => { describe('several uploading attachments', () => { const firstSource = 'blob:https://dev.new.expensify.com:8082/uuid-1'; const secondSource = 'blob:https://dev.new.expensify.com:8082/uuid-2'; - const uploadingImagesHtml = `Hello


`; + const uploadingImagesHtml = `Hello


`; it('reports only the attachments the draft dropped', () => { // Given a draft that keeps the second image but not the first const draft = `Hello edited\n\n!(${secondSource})`; // Then only the first image counts as removed - expect(getUploadingAttachmentSourcesRemovedFromDraft(draft, uploadingImagesHtml)).toEqual([firstSource]); + expect(getUploadingAttachmentIDsRemovedFromDraft(draft, uploadingImagesHtml)).toEqual(['11']); }); it('keeps showing every attachment the edit kept', () => { // When the first image is removed - const html = getUploadingAttachmentHtmlFromComment(uploadingImagesHtml, [firstSource]); + const html = getUploadingAttachmentHtmlFromComment(uploadingImagesHtml, ['11']); // Then only the second image is re-appended to the edited message expect(html).toContain(secondSource); @@ -7886,7 +7903,7 @@ describe('ReportUtils', () => { const secondEditDraft = Parser.htmlToMarkdown(storedAfterFirstEdit).trim(); expect(secondEditDraft).toContain(videoSource); - expect(getUploadingAttachmentSourcesRemovedFromDraft(secondEditDraft, storedAfterFirstEdit)).toEqual([]); + expect(getUploadingAttachmentIDsRemovedFromDraft(secondEditDraft, storedAfterFirstEdit)).toEqual([]); }); }); diff --git a/tests/unit/RequestConflictUtilsTest.ts b/tests/unit/RequestConflictUtilsTest.ts index 6df0193483f1..5a87dde0848d 100644 --- a/tests/unit/RequestConflictUtilsTest.ts +++ b/tests/unit/RequestConflictUtilsTest.ts @@ -152,7 +152,7 @@ describe('RequestConflictUtils', () => { const reportActionID = '2'; const persistedRequests = [{command: 'AddTextAndAttachment', data: {reportActionID, reportComment: 'test', file: {uri: 'blob:local'}, attachmentID: '5'}}, {command: 'OpenReport'}]; const parameters = {reportID: '1', reportActionID, reportComment: 'attachment removed'}; - const result = resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, ['blob:local']); + const result = resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, ['5']); expect(result).toEqual({ conflictAction: { type: 'replace', @@ -175,7 +175,7 @@ describe('RequestConflictUtils', () => { const parameters = {reportID: '1', reportActionID, reportComment: 'b removed'}; // When the edit removes the middle file - const result = resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, ['blob:b']); + const result = resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, ['6']); // Then the request still sends the other two files, renumbered with their own IDs expect(result.conflictAction).toEqual({ @@ -202,7 +202,7 @@ describe('RequestConflictUtils', () => { const parameters = {reportID: '1', reportActionID, reportComment: 'only b left'}; // When the edit removes the first and last files - const result = resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, ['blob:a', 'blob:c']); + const result = resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, ['5', '7']); // Then the request sends the one file left as a single attachment expect(result.conflictAction).toEqual({ From a84ed295b8f40da526b27b0663cc9b86e1deedae Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 19:55:53 +0200 Subject: [PATCH 114/156] Keep measuring sends from the Concierge welcome screen --- src/CONST/index.ts | 1 - src/pages/inbox/report/AskConciergeWelcome.tsx | 4 ---- 2 files changed, 5 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 1918901a717c..dd73a18a45f4 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2645,7 +2645,6 @@ const CONST = { SKELETON_GUARD_LOADING: 'skeleton_guard_loading', SKELETON_GUARD_DERIVED_TIMING: 'skeleton_guard_derived_timing', INBOX_TAB_DEFER: 'inbox_tab_defer', - CONCIERGE_WELCOME: 'concierge_welcome', }, /** Follow-up action after expense submit (action-based; used as submit_follow_up_action in span). */ SUBMIT_FOLLOW_UP_ACTION: { diff --git a/src/pages/inbox/report/AskConciergeWelcome.tsx b/src/pages/inbox/report/AskConciergeWelcome.tsx index 5504a346bba2..b34f5bcd1388 100644 --- a/src/pages/inbox/report/AskConciergeWelcome.tsx +++ b/src/pages/inbox/report/AskConciergeWelcome.tsx @@ -1,13 +1,11 @@ import ScrollView from '@components/ScrollView'; -import useCancelSendMessageSpanOnSkeleton from '@hooks/useCancelSendMessageSpanOnSkeleton'; import useConciergeAskState from '@hooks/useConciergeAskState'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import React from 'react'; @@ -31,8 +29,6 @@ function AskConciergeWelcome({reportID, hasPreviousMessages, onShowPreviousMessa const {shouldCenterWelcomeWithComposer} = useConciergeAskState(reportID); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - useCancelSendMessageSpanOnSkeleton(reportID, CONST.TELEMETRY.CANCELED_BY_SKELETON.CONCIERGE_WELCOME); - return ( Date: Tue, 20 Oct 2026 19:56:12 +0200 Subject: [PATCH 115/156] Remove Concierge ask state tests that cannot fail --- .../unit/hooks/useConciergeAskState.test.tsx | 68 +------------------ 1 file changed, 1 insertion(+), 67 deletions(-) diff --git a/tests/unit/hooks/useConciergeAskState.test.tsx b/tests/unit/hooks/useConciergeAskState.test.tsx index 1c58658a0e6c..e29a8c95b525 100644 --- a/tests/unit/hooks/useConciergeAskState.test.tsx +++ b/tests/unit/hooks/useConciergeAskState.test.tsx @@ -7,8 +7,6 @@ import useConciergeAskState from '@hooks/useConciergeAskState'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type * as NativeNavigation from '@react-navigation/native'; - import Onyx from 'react-native-onyx'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; @@ -26,13 +24,6 @@ jest.mock('@pages/inbox/ConciergeChatHistoryContext', () => ({ useConciergeChatHistoryState: () => ({showFullHistory: false}), })); -let mockRouteParams: {reportActionID?: string} = {}; - -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useRoute: () => ({key: 'Report-test', name: 'Report', params: mockRouteParams}), -})); - describe('useConciergeAskState', () => { beforeAll(async () => { Onyx.init({keys: ONYXKEYS}); @@ -40,10 +31,6 @@ describe('useConciergeAskState', () => { await waitForBatchedUpdates(); }); - beforeEach(() => { - mockRouteParams = {}; - }); - it('shows the empty state once the user has messaged Concierge', async () => { const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); @@ -114,7 +101,7 @@ describe('useConciergeAskState', () => { const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); expect(result.current.shouldShowWelcome).toBe(true); - // When a question sent from another device arrives before its thread is linked + // When a question sent from another device arrives const question = { reportActionID: '3', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, @@ -127,63 +114,10 @@ describe('useConciergeAskState', () => { // Then the welcome stays, so this device doesn't flicker expect(result.current.shouldShowWelcome).toBe(true); - // When its thread is linked - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[question.reportActionID]: {childReportID: '4', childType: CONST.REPORT.TYPE.CHAT}}); - await waitForBatchedUpdates(); - - // Then the welcome still stays, because the question is answered in its thread - expect(result.current.shouldShowWelcome).toBe(true); - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, null); - await waitForBatchedUpdates(); - }); - - it('keeps the empty state when the chat has an open task assigned to the user', async () => { - // Given the whole chat is loaded and has an open task assigned to the current user - const createdAction = { - reportActionID: '7', - actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, - created: '2024-06-01 10:00:00.000', - }; - const task = { - reportActionID: '3', - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - actorAccountID: 100, - created: '2024-06-01 12:05:00.000', - childReportID: '5', - childType: CONST.REPORT.TYPE.TASK, - childManagerAccountID: 100, - childStateNum: CONST.REPORT.STATE_NUM.OPEN, - childStatusNum: CONST.REPORT.STATUS_NUM.OPEN, - }; - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, { - [createdAction.reportActionID]: createdAction, - [task.reportActionID]: task, - }); - await waitForBatchedUpdates(); - - // When the Concierge chat renders - const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); - - // Then the welcome shows, and the task stays behind `View chat history` - expect(result.current.shouldShowWelcome).toBe(true); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, null); await waitForBatchedUpdates(); }); - it('keeps the empty state when the report opens at a linked action', () => { - // Given a route that opens the Concierge chat at a linked message - mockRouteParams = {reportActionID: '2'}; - - // When the Concierge chat renders - const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); - - // Then the welcome shows, because only `View chat history` expands the earlier conversation - expect(result.current.isHistoryExpanded).toBe(false); - expect(result.current.shouldShowWelcome).toBe(true); - }); - it('stays off outside the Concierge report', () => { // Given a report that isn't the Concierge chat const reportID = '999'; From 5862a8f4f8914ee1a0d115fef6db69704fce7d62 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 19:56:39 +0200 Subject: [PATCH 116/156] Drop a stale Concierge test and always restore the navigate spy --- tests/ui/ReportActionsListTest.tsx | 17 ----------------- tests/ui/components/HeaderViewTest.tsx | 6 ++++-- 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index d38c06d033d4..d70161ff9d7a 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -1190,22 +1190,5 @@ describe('ReportActionsList (body)', () => { expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(true); expect(passedActions?.some((a) => a.reportActionID === 'old-concierge-msg')).toBe(true); }); - - it('should render cached actions without a skeleton on refresh when hasOnceLoadedReportActions resets but actions are cached', () => { - // Given a page refresh: hasOnceLoadedReportActions is RAM-only and resets to false, but report actions persist in Onyx - setupMainDMConciergeMocks(true, false); - mockUsePaginatedReportActions.mockReturnValue({ - ...defaultPaginatedReportActionsResult, - reportActions: oldReportActions, - hasOlderActions: false, - }); - - // When the list renders - renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - - // Then the cached actions render right away - expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); - expect(mockInvertedFlashList).toHaveBeenCalled(); - }); }); }); diff --git a/tests/ui/components/HeaderViewTest.tsx b/tests/ui/components/HeaderViewTest.tsx index cf277887f7b5..7adba66b190e 100644 --- a/tests/ui/components/HeaderViewTest.tsx +++ b/tests/ui/components/HeaderViewTest.tsx @@ -49,6 +49,8 @@ const mockUseCurrentUserPersonalDetails = jest.mocked(useCurrentUserPersonalDeta const currentUserAccountID = 1; describe('HeaderView', () => { + let navigateSpy: jest.SpyInstance | undefined; + beforeEach(() => { // Set up default mock return value mockUseCurrentUserPersonalDetails.mockReturnValue({ @@ -57,6 +59,7 @@ describe('HeaderView', () => { }); afterEach(async () => { + navigateSpy?.mockRestore(); jest.clearAllMocks(); await Onyx.clear(); }); @@ -132,7 +135,7 @@ describe('HeaderView', () => { await Onyx.set(ONYXKEYS.CONCIERGE_REPORT_ID, conciergeReportID); await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${threadReport.reportID}`, threadReport); await waitForBatchedUpdates(); - const navigateSpy = jest.spyOn(NavigationActions, 'navigate').mockImplementation(() => {}); + navigateSpy = jest.spyOn(NavigationActions, 'navigate').mockImplementation(() => {}); render( @@ -149,7 +152,6 @@ describe('HeaderView', () => { // Then the user is taken back to the Concierge chat to ask another question expect(navigateSpy).toHaveBeenCalledWith(expect.stringContaining(`r/${conciergeReportID}`)); - navigateSpy.mockRestore(); }); it('should display join button', async () => { From 45dbfa0aec6fc4b4831649c1663c438b594e4487 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 19:56:46 +0200 Subject: [PATCH 117/156] Drop an unused test helper parameter --- tests/ui/ReportActionsListTest.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index d70161ff9d7a..107fad2d2e1e 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -1113,7 +1113,7 @@ describe('ReportActionsList (body)', () => { }, ]; - const setupMainDMConciergeMocks = (showFullHistory = false, hasOnceLoadedReportActions = true) => { + const setupMainDMConciergeMocks = (showFullHistory = false) => { jest.spyOn(ReportActionsUtils, 'shouldReportActionBeVisible').mockReturnValue(true); mockUseNetwork.mockReturnValue({isOffline: false}); mockUseIsInSidePanel.mockReturnValue(false); @@ -1132,7 +1132,7 @@ describe('ReportActionsList (body)', () => { return [false, {status: 'loaded'}]; } if (key.includes('reportLoadingState')) { - return [getMockReportLoadingState(options?.selector, hasOnceLoadedReportActions), {status: 'loaded'}]; + return [getMockReportLoadingState(options?.selector), {status: 'loaded'}]; } if (key.includes('reportActions')) { return [[], {status: 'loaded'}]; From ab2a96903b6f54289aaa0d29c57e54e08adb2fa8 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 19:56:56 +0200 Subject: [PATCH 118/156] Reuse the shared session account ID selector --- src/pages/inbox/ConciergeChatHistoryContext.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pages/inbox/ConciergeChatHistoryContext.tsx b/src/pages/inbox/ConciergeChatHistoryContext.tsx index b5b4711c645a..e5789d1067c6 100644 --- a/src/pages/inbox/ConciergeChatHistoryContext.tsx +++ b/src/pages/inbox/ConciergeChatHistoryContext.tsx @@ -4,6 +4,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {PropsWithChildren} from 'react'; +import {accountIDSelector} from '@selectors/Session'; import React, {createContext, useContext, useState} from 'react'; type ConciergeChatHistoryStateContextType = { @@ -23,8 +24,6 @@ const ConciergeChatHistoryActionsContext = createContext {}, }); -const accountIDSelector = (session: {accountID?: number} | undefined) => session?.accountID; - function ConciergeChatHistoryProvider({children}: PropsWithChildren) { const [showFullHistory, setShowFullHistory] = useState(false); From c0175cc2c33e095d45885ed2dbf0048c1ff9c628 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 19:57:11 +0200 Subject: [PATCH 119/156] Restore the side panel's earlier-messages check --- src/hooks/useConciergeSidePanelReportActions.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/hooks/useConciergeSidePanelReportActions.ts b/src/hooks/useConciergeSidePanelReportActions.ts index 695f9083f3e7..c8128c4b3e8c 100644 --- a/src/hooks/useConciergeSidePanelReportActions.ts +++ b/src/hooks/useConciergeSidePanelReportActions.ts @@ -79,6 +79,14 @@ function useConciergeSidePanelReportActions({ return hasUserMessageInLoadedSet || hasOlderActions; }, [isConciergeHiddenHistory, visibleReportActions, currentUserAccountID, sessionStartTime, hasOlderActions]); + const hasPreviousMessages = useMemo(() => { + if (!isConciergeHiddenHistory || !hadUserMessageAtSessionStart || !sessionStartTime) { + return false; + } + const hasPreSessionActionInLoadedSet = visibleReportActions.some((action) => !isCreatedAction(action) && action.created < sessionStartTime); + return hasPreSessionActionInLoadedSet || hasOlderActions; + }, [isConciergeHiddenHistory, visibleReportActions, sessionStartTime, hadUserMessageAtSessionStart, hasOlderActions]); + // A re-stamp flips `hasUserSentMessage` too, so the sticky set is what keeps the welcome state from coming back. const hasSentInSession = hasUserSentMessage || sessionSentActionIDs.size > 0; const showConciergeSidePanelWelcome = isConciergeHiddenHistory && hadUserMessageAtSessionStart && !hasSentInSession && !showFullHistory; @@ -174,13 +182,6 @@ function useConciergeSidePanelReportActions({ const filteredVisibleActions = useMemo(() => filterActions(visibleReportActions), [filterActions, visibleReportActions]); const filteredReportActions = useMemo(() => filterActions(reportActions), [filterActions, reportActions]); - const hasPreviousMessages = useMemo(() => { - if (!isConciergeHiddenHistory || !hadUserMessageAtSessionStart || !sessionStartTime) { - return false; - } - return visibleReportActions.some((action) => !isCurrentSessionAction(action)) || hasOlderActions; - }, [isConciergeHiddenHistory, hadUserMessageAtSessionStart, sessionStartTime, visibleReportActions, isCurrentSessionAction, hasOlderActions]); - const handleShowPreviousMessages = useCallback(() => { setShowFullHistory(true); loadOlderChats(true); From fb0a4c404ccd065fb0745ac46a1fe643f779eb09 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 19:58:38 +0200 Subject: [PATCH 120/156] Only scan Concierge messages where the welcome screen is decided --- src/hooks/useConciergeAskState.ts | 19 +++--------- src/hooks/useIsAskConciergeChat.ts | 13 ++++++++ .../inbox/report/AskConciergeWelcome.tsx | 7 ++--- .../report/ConciergeChatHistoryToggle.tsx | 9 ++---- .../ReportActionCompose/ComposerInput.tsx | 4 +-- src/pages/inbox/report/ReportActionsList.tsx | 1 - .../report/ReportActionsListPaddingView.tsx | 4 +-- src/pages/inbox/report/ReportFooter.tsx | 4 +-- tests/ui/ReportActionsListTest.tsx | 2 -- tests/ui/ReportFooterTest.tsx | 2 -- tests/unit/ConciergeChatHistoryToggleTest.tsx | 30 +++++-------------- .../unit/ReportActionsListPaddingViewTest.tsx | 2 +- .../unit/hooks/useConciergeAskState.test.tsx | 2 -- 13 files changed, 36 insertions(+), 63 deletions(-) create mode 100644 src/hooks/useIsAskConciergeChat.ts diff --git a/src/hooks/useConciergeAskState.ts b/src/hooks/useConciergeAskState.ts index ab366ea070c4..49fab5b60a27 100644 --- a/src/hooks/useConciergeAskState.ts +++ b/src/hooks/useConciergeAskState.ts @@ -8,15 +8,12 @@ import type {ReportActions} from '@src/types/onyx/ReportAction'; import type {OnyxEntry} from 'react-native-onyx'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; -import useIsInSidePanel from './useIsInSidePanel'; +import useIsAskConciergeChat from './useIsAskConciergeChat'; import useOnyx from './useOnyx'; import useResponsiveLayout from './useResponsiveLayout'; type ConciergeAskState = { - /** Whether this is the Concierge chat in the Inbox */ - isAskConciergeChat: boolean; - - /** Whether the earlier conversation is shown */ + /** Whether the earlier conversation of the Concierge chat in the Inbox is shown */ isHistoryExpanded: boolean; /** Whether to show the `Ask me anything!` welcome screen in place of the messages */ @@ -24,19 +21,13 @@ type ConciergeAskState = { /** Whether the welcome screen and the composer are centered together on the screen */ shouldCenterWelcomeWithComposer: boolean; - - /** Whether to show `Ask a new question` above the composer */ - shouldLabelComposerAsNewQuestion: boolean; }; function useConciergeAskState(reportID: string | undefined): ConciergeAskState { - const isInSidePanel = useIsInSidePanel(); + const isAskConciergeChat = useIsAskConciergeChat(reportID); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {showFullHistory} = useConciergeChatHistoryState(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); - const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - - const isAskConciergeChat = !!reportID && reportID === conciergeReportID && !isInSidePanel; const shouldKeepMessagesOnScreenSelector = (actions: OnyxEntry) => { if (!isAskConciergeChat || !actions) { @@ -59,11 +50,9 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { const shouldShowWelcome = isAskConciergeChat && !showFullHistory && !shouldKeepMessagesOnScreen; return { - isAskConciergeChat, - isHistoryExpanded: showFullHistory, + isHistoryExpanded: isAskConciergeChat && showFullHistory, shouldShowWelcome, shouldCenterWelcomeWithComposer: shouldShowWelcome && !shouldUseNarrowLayout, - shouldLabelComposerAsNewQuestion: isAskConciergeChat && showFullHistory, }; } diff --git a/src/hooks/useIsAskConciergeChat.ts b/src/hooks/useIsAskConciergeChat.ts new file mode 100644 index 000000000000..443bba256b9b --- /dev/null +++ b/src/hooks/useIsAskConciergeChat.ts @@ -0,0 +1,13 @@ +import ONYXKEYS from '@src/ONYXKEYS'; + +import useIsInSidePanel from './useIsInSidePanel'; +import useOnyx from './useOnyx'; + +/** Whether the report is the Concierge chat in the Inbox */ +function useIsAskConciergeChat(reportID: string | undefined): boolean { + const isInSidePanel = useIsInSidePanel(); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + return !!reportID && reportID === conciergeReportID && !isInSidePanel; +} + +export default useIsAskConciergeChat; diff --git a/src/pages/inbox/report/AskConciergeWelcome.tsx b/src/pages/inbox/report/AskConciergeWelcome.tsx index b34f5bcd1388..b1a546788b36 100644 --- a/src/pages/inbox/report/AskConciergeWelcome.tsx +++ b/src/pages/inbox/report/AskConciergeWelcome.tsx @@ -1,7 +1,7 @@ import ScrollView from '@components/ScrollView'; -import useConciergeAskState from '@hooks/useConciergeAskState'; import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; @@ -26,19 +26,18 @@ type AskConciergeWelcomeProps = { function AskConciergeWelcome({reportID, hasPreviousMessages, onShowPreviousMessages}: AskConciergeWelcomeProps) { const styles = useThemeStyles(); - const {shouldCenterWelcomeWithComposer} = useConciergeAskState(reportID); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); return ( markOpenReportEnd(reportID, report, {warm: true})} > ; }; -function ConciergeChatHistoryToggle({reportID, hasPreviousMessages, shouldShowFullHistory, onShowPreviousMessages, containerStyles}: ConciergeChatHistoryToggleProps) { +function ConciergeChatHistoryToggle({hasPreviousMessages, shouldShowFullHistory, onShowPreviousMessages, containerStyles}: ConciergeChatHistoryToggleProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['UpArrow', 'DownArrow']); - const {isAskConciergeChat} = useConciergeAskState(reportID); const {setShowFullHistory} = useConciergeChatHistoryActions(); const hideChatHistory = () => setShowFullHistory(false); - if (!isAskConciergeChat || !hasPreviousMessages) { + if (!hasPreviousMessages) { return null; } diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx index 445093d4fc5f..46a93c6bec81 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx @@ -1,6 +1,6 @@ import type {LocalizedTranslate} from '@components/LocaleContextProvider'; -import useConciergeAskState from '@hooks/useConciergeAskState'; +import useIsAskConciergeChat from '@hooks/useIsAskConciergeChat'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useReportIsArchived from '@hooks/useReportIsArchived'; @@ -85,7 +85,7 @@ function ComposerInput() { const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const isReportArchived = useReportIsArchived(report?.reportID); - const {isAskConciergeChat} = useConciergeAskState(reportID); + const isAskConciergeChat = useIsAskConciergeChat(reportID); const [askConciergePlaceholderKey] = useState(() => ASK_CONCIERGE_PLACEHOLDER_KEYS[Math.floor(Math.random() * ASK_CONCIERGE_PLACEHOLDER_KEYS.length)]); const includesConcierge = chatIncludesConcierge({participants: report?.participants}); diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 07b90ad1fe3f..159cab18f568 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -508,7 +508,6 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct <> {isHistoryExpanded && ( {children} diff --git a/src/pages/inbox/report/ReportFooter.tsx b/src/pages/inbox/report/ReportFooter.tsx index 25ac35c9c71e..a991701e1c73 100644 --- a/src/pages/inbox/report/ReportFooter.tsx +++ b/src/pages/inbox/report/ReportFooter.tsx @@ -101,7 +101,7 @@ function ReportFooter() { const isSystemChat = isSystemChatUtil(report); const isAdminsOnlyPostingRoom = isAdminsOnlyPostingRoomUtil(report); const shouldShowComposerForActiveEditDraft = useShouldShowComposerForActiveEditDraft(); - const {shouldShowWelcome: shouldShowConciergeWelcome, shouldLabelComposerAsNewQuestion} = useConciergeAskState(reportIDFromRoute); + const {shouldShowWelcome: shouldShowConciergeWelcome, isHistoryExpanded: isConciergeHistoryExpanded} = useConciergeAskState(reportIDFromRoute); const shouldShowResolvedSupportTicketFooter = isResolvedSupportTicket(report) && report?.ownerAccountID === currentUserAccountID; if (!isCurrentReportLoadedFromOnyx || !report || !reportIDFromRoute) { @@ -142,7 +142,7 @@ function ReportFooter() { return ( {merchantRuleBanner} - {shouldLabelComposerAsNewQuestion && !isComposerFullSize && {translate('common.concierge.askNewQuestion')}} + {isConciergeHistoryExpanded && !isComposerFullSize && {translate('common.concierge.askNewQuestion')}} {shouldShowEnableNotificationsBanner ? ( <> diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 107fad2d2e1e..a19d4f73970d 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -1151,11 +1151,9 @@ describe('ReportActionsList (body)', () => { // Given the main Concierge chat with its history collapsed setupMainDMConciergeMocks(); jest.mocked(useConciergeAskState).mockReturnValue({ - isAskConciergeChat: true, isHistoryExpanded: false, shouldShowWelcome: true, shouldCenterWelcomeWithComposer: true, - shouldLabelComposerAsNewQuestion: false, }); mockUsePaginatedReportActions.mockReturnValue({ ...defaultPaginatedReportActionsResult, diff --git a/tests/ui/ReportFooterTest.tsx b/tests/ui/ReportFooterTest.tsx index e4b5b8fa25f1..dfd05766e4b8 100644 --- a/tests/ui/ReportFooterTest.tsx +++ b/tests/ui/ReportFooterTest.tsx @@ -110,11 +110,9 @@ describe('ReportFooter', () => { // Given the main Concierge chat with its history expanded mockLayout({isSmallScreenWidth: false, isInNarrowPaneModal: false}); jest.mocked(useConciergeAskState).mockReturnValue({ - isAskConciergeChat: true, isHistoryExpanded: true, shouldShowWelcome: false, shouldCenterWelcomeWithComposer: false, - shouldLabelComposerAsNewQuestion: true, }); await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportID: REPORT_ID, type: CONST.REPORT.TYPE.CHAT}); diff --git a/tests/unit/ConciergeChatHistoryToggleTest.tsx b/tests/unit/ConciergeChatHistoryToggleTest.tsx index 589da25cf5d4..668e361fd3b3 100644 --- a/tests/unit/ConciergeChatHistoryToggleTest.tsx +++ b/tests/unit/ConciergeChatHistoryToggleTest.tsx @@ -1,20 +1,13 @@ import {fireEvent, render, screen} from '@testing-library/react-native'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; -import OnyxListItemProvider from '@components/OnyxListItemProvider'; - -import {IsInSidePanelContext} from '@hooks/useIsInSidePanel'; import ConciergeChatHistoryToggle from '@pages/inbox/report/ConciergeChatHistoryToggle'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; -import ONYXKEYS from '@src/ONYXKEYS'; import React from 'react'; -import Onyx from 'react-native-onyx'; - -import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; jest.mock('@hooks/useLazyAsset', () => ({ useMemoizedLazyExpensifyIcons: () => ({UpArrow: 'UpArrow', DownArrow: 'DownArrow'}), @@ -27,23 +20,16 @@ jest.mock('@pages/inbox/ConciergeChatHistoryContext', () => ({ useConciergeChatHistoryActions: () => ({setShowFullHistory: mockSetShowFullHistory}), })); -const CONCIERGE_REPORT_ID = '1'; - const renderToggle = (overrides: {hasPreviousMessages?: boolean; shouldShowFullHistory?: boolean} = {}) => { const onShowPreviousMessages = jest.fn(); render( - - - - - - - , + + + , ); return {onShowPreviousMessages}; }; @@ -51,8 +37,6 @@ const renderToggle = (overrides: {hasPreviousMessages?: boolean; shouldShowFullH describe('ConciergeChatHistoryToggle', () => { beforeAll(async () => { await IntlStore.load(CONST.LOCALES.EN); - await Onyx.set(ONYXKEYS.CONCIERGE_REPORT_ID, CONCIERGE_REPORT_ID); - await waitForBatchedUpdates(); }); beforeEach(() => { diff --git a/tests/unit/ReportActionsListPaddingViewTest.tsx b/tests/unit/ReportActionsListPaddingViewTest.tsx index cacab2900a3c..2e8d8689bc0c 100644 --- a/tests/unit/ReportActionsListPaddingViewTest.tsx +++ b/tests/unit/ReportActionsListPaddingViewTest.tsx @@ -26,7 +26,7 @@ jest.mock('@libs/ReportUtils', () => ({ })); jest.mock('@hooks/useConciergeAskState', () => () => ({ - shouldLabelComposerAsNewQuestion: false, + isHistoryExpanded: false, })); jest.mock('@hooks/useThemeStyles', () => () => ({ diff --git a/tests/unit/hooks/useConciergeAskState.test.tsx b/tests/unit/hooks/useConciergeAskState.test.tsx index e29a8c95b525..dde8cc7f3ec6 100644 --- a/tests/unit/hooks/useConciergeAskState.test.tsx +++ b/tests/unit/hooks/useConciergeAskState.test.tsx @@ -34,7 +34,6 @@ describe('useConciergeAskState', () => { it('shows the empty state once the user has messaged Concierge', async () => { const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); - expect(result.current.isAskConciergeChat).toBe(true); expect(result.current.isHistoryExpanded).toBe(false); expect(result.current.shouldShowWelcome).toBe(true); @@ -126,7 +125,6 @@ describe('useConciergeAskState', () => { const {result} = renderHook(() => useConciergeAskState(reportID), {wrapper}); // Then none of the Ask Concierge screen applies to it - expect(result.current.isAskConciergeChat).toBe(false); expect(result.current.shouldShowWelcome).toBe(false); }); }); From 0cf0d7d1d07262561b10a83ed1a5e7b42a6def9a Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 20:57:01 +0200 Subject: [PATCH 121/156] Fall back to matching edited attachments by position --- src/libs/ReportUtils.ts | 11 ++++------- tests/unit/ReportUtilsTest.ts | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 133b2ab92a55..8e63d3f1c105 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -7598,18 +7598,15 @@ function replaceLocalAttachmentReferences( return draftMarkdown; } - // Without a known attachment ID, a reference can only be matched by position when none were removed. + // Matching by position is only safe when the draft kept every attachment. const canMatchByPosition = localReferences.length === syncedAttachmentTags.length; let localReferenceIndex = 0; return draftMarkdown.replace(localAttachmentReferenceRegex, (match) => { const localSource = match.match(/\(([^)]*)\)$/)?.at(1) ?? ''; const attachmentID = attachmentIDByLocalSource[localSource]; - let syncedAttachmentTag: string | undefined; - if (attachmentID) { - syncedAttachmentTag = syncedAttachmentTags.find((tag) => tag.match(CONST.REGEX.ATTACHMENT.ATTACHMENT_ID)?.at(2) === attachmentID); - } else if (canMatchByPosition) { - syncedAttachmentTag = syncedAttachmentTags.at(localReferenceIndex); - } + const syncedAttachmentTag = + (attachmentID ? syncedAttachmentTags.find((tag) => tag.match(CONST.REGEX.ATTACHMENT.ATTACHMENT_ID)?.at(2) === attachmentID) : undefined) ?? + (canMatchByPosition ? syncedAttachmentTags.at(localReferenceIndex) : undefined); localReferenceIndex++; if (!syncedAttachmentTag) { return ''; diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 74a5c42ed8fa..157acfcca3b1 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -7783,6 +7783,20 @@ describe('ReportUtils', () => { ); }); + it('matches by position when the synced attachments have no attachment ID', () => { + // Given synced images without an attachment ID + const syncedHtmlWithoutIDs = syncedTwoImagesHtml.replaceAll(/ data-attachment-id="\d+"/g, ''); + const draft = `Hello edited\n\n!(${firstSource})\n\n!(${secondSource})`; + + // When the edit is saved + const savedDraft = replaceLocalAttachmentReferences(draft, syncedHtmlWithoutIDs, reportActionID, attachmentIDByLocalSource); + + // Then both images stay in the message + expect(savedDraft).toBe( + `Hello edited\n\n![photo.jpg](https://www.expensify.com/chat-attachments/${reportActionID}/w_abc.jpg)\n\n![receipt.jpg](https://www.expensify.com/chat-attachments/${reportActionID}/w_def.jpg)`, + ); + }); + it('keeps the image the user kept when they deleted the other one', () => { // Given the user deleted the first image while both were uploading const draft = `Hello edited\n\n!(${secondSource})`; From b5551aff451c96336abb11ce24d500e3a785377a Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 20:58:08 +0200 Subject: [PATCH 122/156] Decide the Concierge welcome screen once in the list model --- src/hooks/useReportActionsListModel.ts | 4 ++ .../inbox/report/AskConciergeWelcome.tsx | 13 +++-- .../report/ConciergeChatHistoryToggle.tsx | 28 +++------ src/pages/inbox/report/ReportActionsList.tsx | 13 +++-- .../report/ReportActionsListPaddingView.tsx | 9 ++- .../report/ReportActionsSkeletonGuard.tsx | 35 ++++++----- tests/unit/ConciergeChatHistoryToggleTest.tsx | 58 ++++++------------- .../unit/ReportActionsListPaddingViewTest.tsx | 4 +- 8 files changed, 71 insertions(+), 93 deletions(-) diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index 48860e4a9096..51dd43e88d7d 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -11,6 +11,7 @@ import {reportActionsListLoadingStateSelector} from '@src/selectors/ReportMetaDa import {useRoute} from '@react-navigation/native'; +import useConciergeAskState from './useConciergeAskState'; import {useIsAppLoadPending} from './useInFlightRequests'; import useLoadReportActions from './useLoadReportActions'; import useNetworkWithOfflineStatus from './useNetworkWithOfflineStatus'; @@ -62,6 +63,7 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const {showFullHistory: conciergeShowFullHistory} = useConciergeChatHistoryState(); const {setShowFullHistory: setConciergeShowFullHistory} = useConciergeChatHistoryActions(); + const {shouldShowWelcome: shouldShowConciergeWelcome, isHistoryExpanded: isConciergeHistoryExpanded} = useConciergeAskState(reportID); const isReportTransactionThread = isReportTransactionThreadUtil(report); const shouldBeAlignedToTop = shouldReportAlignToTop(report, parentReportAction); @@ -146,6 +148,8 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea showFullHistory, hasPreviousMessages, allReportActionIDs, + shouldShowConciergeWelcome, + isConciergeHistoryExpanded, }; // The command handles on `ReportActionsListActionsContext`. Referentially stable, so actions-only diff --git a/src/pages/inbox/report/AskConciergeWelcome.tsx b/src/pages/inbox/report/AskConciergeWelcome.tsx index b1a546788b36..e2061c9c0191 100644 --- a/src/pages/inbox/report/AskConciergeWelcome.tsx +++ b/src/pages/inbox/report/AskConciergeWelcome.tsx @@ -37,12 +37,13 @@ function AskConciergeWelcome({reportID, hasPreviousMessages, onShowPreviousMessa onLayout={() => markOpenReportEnd(reportID, report, {warm: true})} > - + {hasPreviousMessages && ( + + )} ); } diff --git a/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx b/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx index 40178da7d365..9953db9445fd 100644 --- a/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx +++ b/src/pages/inbox/report/ConciergeChatHistoryToggle.tsx @@ -4,8 +4,6 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import {useConciergeChatHistoryActions} from '@pages/inbox/ConciergeChatHistoryContext'; - import CONST from '@src/CONST'; import type {StyleProp, ViewStyle} from 'react-native'; @@ -14,40 +12,30 @@ import React from 'react'; import {View} from 'react-native'; type ConciergeChatHistoryToggleProps = { - /** Whether the chat has earlier messages to show */ - hasPreviousMessages: boolean; - - /** Whether the earlier conversation is currently shown */ - shouldShowFullHistory: boolean; + /** Whether the earlier conversation is shown */ + isHistoryShown: boolean; - /** Callback to reveal the earlier conversation */ - onShowPreviousMessages: () => void; + /** Called when the button is pressed */ + onPress: () => void; /** Styles applied to the row wrapping the divider and the button */ containerStyles?: StyleProp; }; -function ConciergeChatHistoryToggle({hasPreviousMessages, shouldShowFullHistory, onShowPreviousMessages, containerStyles}: ConciergeChatHistoryToggleProps) { +function ConciergeChatHistoryToggle({isHistoryShown, onPress, containerStyles}: ConciergeChatHistoryToggleProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['UpArrow', 'DownArrow']); - const {setShowFullHistory} = useConciergeChatHistoryActions(); - - const hideChatHistory = () => setShowFullHistory(false); - - if (!hasPreviousMessages) { - return null; - } return ( diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 159cab18f568..08975589032f 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -4,7 +4,6 @@ import MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBann import {ReportActionsAnimatedSkeletonCover} from '@components/ReportActionsSkeletonCover'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; -import useConciergeAskState from '@hooks/useConciergeAskState'; import useEnvironment from '@hooks/useEnvironment'; import useLinkedMessageOfflineLoading from '@hooks/useLinkedMessageOfflineLoading'; import useLocalize from '@hooks/useLocalize'; @@ -56,6 +55,7 @@ import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; import type {ReportsSplitNavigatorParamList} from '@navigation/types'; import {useActionListContext, useActionListRef} from '@pages/inbox/ActionListContext'; +import {useConciergeChatHistoryActions} from '@pages/inbox/ConciergeChatHistoryContext'; import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; import CONST from '@src/CONST'; @@ -140,6 +140,8 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct showFullHistory, hasPreviousMessages, allReportActionIDs, + shouldShowConciergeWelcome, + isConciergeHistoryExpanded, } = useReportActionsListState(); const {setTreatAsNoPaginationAnchor, loadOlderChats, loadNewerChats, handleShowPreviousMessages} = useReportActionsListActions(); @@ -148,7 +150,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const route = useRoute>(); const reportActionIDFromRoute = route?.params?.reportActionID; const {sessionStartTime} = useSidePanelState(); - const {shouldShowWelcome: shouldShowConciergeWelcome, isHistoryExpanded} = useConciergeAskState(reportID); + const {setShowFullHistory: setConciergeShowFullHistory} = useConciergeChatHistoryActions(); const didLayout = useRef(false); @@ -506,11 +508,10 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const listHeaderComponent = ( <> - {isHistoryExpanded && ( + {isConciergeHistoryExpanded && !!hasPreviousMessages && ( setConciergeShowFullHistory(false)} /> )} {children} diff --git a/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx b/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx index f5a02262231b..8eefc5b389b5 100644 --- a/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx +++ b/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx @@ -1,5 +1,4 @@ import useBackfillWhenNoVisibleActions from '@hooks/useBackfillWhenNoVisibleActions'; -import useConciergeAskState from '@hooks/useConciergeAskState'; import useCopySelectionEvent from '@hooks/useCopySelectionEvent'; import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; import usePendingConciergeResponse from '@hooks/usePendingConciergeResponse'; @@ -33,7 +32,6 @@ type ReportActionsSkeletonGuardProps = { */ function ReportActionsSkeletonGuard({reportID, children}: ReportActionsSkeletonGuardProps) { const isReportLoadPending = useIsReportLoadPending(reportID); - const {shouldShowWelcome} = useConciergeAskState(reportID); const {readinessSignals, state, actions} = useReportActionsListModel(reportID, isReportLoadPending); const {shouldShowLoadingSkeleton, shouldShowDerivedTimingSkeleton} = computeReportActionsSkeletonState(readinessSignals); @@ -56,22 +54,31 @@ function ReportActionsSkeletonGuard({reportID, children}: ReportActionsSkeletonG loadOlderChats: actions.loadOlderChats, }); - if (shouldShowLoadingSkeleton || shouldShowDerivedTimingSkeleton) { - if (shouldShowWelcome) { - return ( - - ); - } + if ((shouldShowLoadingSkeleton || shouldShowDerivedTimingSkeleton) && state.shouldShowConciergeWelcome) { + return ( + + ); + } + + if (shouldShowLoadingSkeleton) { + return ( + + ); + } + if (shouldShowDerivedTimingSkeleton) { return ( ); } diff --git a/tests/unit/ConciergeChatHistoryToggleTest.tsx b/tests/unit/ConciergeChatHistoryToggleTest.tsx index 668e361fd3b3..d9c741fdc36a 100644 --- a/tests/unit/ConciergeChatHistoryToggleTest.tsx +++ b/tests/unit/ConciergeChatHistoryToggleTest.tsx @@ -13,25 +13,17 @@ jest.mock('@hooks/useLazyAsset', () => ({ useMemoizedLazyExpensifyIcons: () => ({UpArrow: 'UpArrow', DownArrow: 'DownArrow'}), })); -const mockSetShowFullHistory = jest.fn(); - -jest.mock('@pages/inbox/ConciergeChatHistoryContext', () => ({ - useConciergeChatHistoryState: () => ({showFullHistory: false}), - useConciergeChatHistoryActions: () => ({setShowFullHistory: mockSetShowFullHistory}), -})); - -const renderToggle = (overrides: {hasPreviousMessages?: boolean; shouldShowFullHistory?: boolean} = {}) => { - const onShowPreviousMessages = jest.fn(); +const renderToggle = (isHistoryShown: boolean) => { + const onPress = jest.fn(); render( , ); - return {onShowPreviousMessages}; + return {onPress}; }; describe('ConciergeChatHistoryToggle', () => { @@ -39,41 +31,25 @@ describe('ConciergeChatHistoryToggle', () => { await IntlStore.load(CONST.LOCALES.EN); }); - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('reveals the earlier conversation when history is hidden', () => { + it('offers to show the earlier conversation while it is hidden', () => { // Given the earlier conversation is hidden - const {onShowPreviousMessages} = renderToggle(); - expect(screen.getByText('View chat history')).toBeTruthy(); + const {onPress} = renderToggle(false); - // When the user presses the toggle - fireEvent.press(screen.getByRole('button')); + // When the user presses `View chat history` + fireEvent.press(screen.getByText('View chat history')); - // Then the earlier conversation is revealed - expect(onShowPreviousMessages).toHaveBeenCalledTimes(1); + // Then the press is handled + expect(onPress).toHaveBeenCalledTimes(1); }); - it('collapses the earlier conversation when history is shown', () => { + it('offers to hide the earlier conversation while it is shown', () => { // Given the earlier conversation is shown - const {onShowPreviousMessages} = renderToggle({shouldShowFullHistory: true}); - expect(screen.getByText('Hide chat history')).toBeTruthy(); - - // When the user presses the toggle - fireEvent.press(screen.getByRole('button')); - - // Then the history collapses, so the chat goes back to its welcome state - expect(mockSetShowFullHistory).toHaveBeenCalledWith(false); - expect(onShowPreviousMessages).not.toHaveBeenCalled(); - }); + const {onPress} = renderToggle(true); - it('renders nothing when there is no earlier conversation to reveal', () => { - // Given a Concierge chat with no earlier conversation - // When the toggle renders - renderToggle({hasPreviousMessages: false}); + // When the user presses `Hide chat history` + fireEvent.press(screen.getByText('Hide chat history')); - // Then there is nothing to toggle, so no button shows - expect(screen.queryByRole('button')).toBeNull(); + // Then the press is handled + expect(onPress).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/unit/ReportActionsListPaddingViewTest.tsx b/tests/unit/ReportActionsListPaddingViewTest.tsx index 2e8d8689bc0c..f8ddfef60402 100644 --- a/tests/unit/ReportActionsListPaddingViewTest.tsx +++ b/tests/unit/ReportActionsListPaddingViewTest.tsx @@ -25,9 +25,7 @@ jest.mock('@libs/ReportUtils', () => ({ canUserPerformWriteAction: jest.fn(), })); -jest.mock('@hooks/useConciergeAskState', () => () => ({ - isHistoryExpanded: false, -})); +jest.mock('@hooks/useIsAskConciergeChat', () => () => false); jest.mock('@hooks/useThemeStyles', () => () => ({ flex1: {flex: 1}, From 6fe2b32337fe9a9fd6635175234a9f07ff51b5b6 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 20:58:45 +0200 Subject: [PATCH 123/156] Drive the Concierge welcome list test from report actions --- tests/ui/ReportActionsListTest.tsx | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index a19d4f73970d..bf928880afc7 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -1,6 +1,5 @@ import {render, screen, waitFor} from '@testing-library/react-native'; -import useConciergeAskState from '@hooks/useConciergeAskState'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; /* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ @@ -51,10 +50,6 @@ jest.mock('@react-navigation/native', () => { }); jest.mock('@hooks/useNetwork', () => jest.fn()); -jest.mock('@hooks/useConciergeAskState', () => ({ - __esModule: true, - default: jest.fn(jest.requireActual<{default: typeof useConciergeAskState}>('@hooks/useConciergeAskState').default), -})); jest.mock('@pages/inbox/report/AskConciergeWelcome', () => { const {View} = jest.requireActual('react-native'); return () => ; @@ -292,7 +287,6 @@ describe('ReportActionsList (body)', () => { beforeEach(() => { jest.clearAllMocks(); - jest.mocked(useConciergeAskState).mockImplementation(jest.requireActual<{default: typeof useConciergeAskState}>('@hooks/useConciergeAskState').default); mockUseIsReportLoadPending.mockReturnValue(false); mockUseCurrentUserPersonalDetails.mockReturnValue({ @@ -1134,6 +1128,10 @@ describe('ReportActionsList (body)', () => { if (key.includes('reportLoadingState')) { return [getMockReportLoadingState(options?.selector), {status: 'loaded'}]; } + if (key === `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}` && options?.selector) { + const reportActions = Object.fromEntries(oldReportActions.map((action) => [action.reportActionID, action])); + return [(options.selector as (actions: OnyxTypes.ReportActions) => unknown)(reportActions), {status: 'loaded'}]; + } if (key.includes('reportActions')) { return [[], {status: 'loaded'}]; } @@ -1148,13 +1146,8 @@ describe('ReportActionsList (body)', () => { }; it('should show the welcome screen in place of the conversation by default', () => { - // Given the main Concierge chat with its history collapsed + // Given the main Concierge chat where the user asked before, with its history collapsed setupMainDMConciergeMocks(); - jest.mocked(useConciergeAskState).mockReturnValue({ - isHistoryExpanded: false, - shouldShowWelcome: true, - shouldCenterWelcomeWithComposer: true, - }); mockUsePaginatedReportActions.mockReturnValue({ ...defaultPaginatedReportActionsResult, reportActions: oldReportActions, From 6882047e96161cfaaef01f2f78cab4f1fef982e2 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 20:58:46 +0200 Subject: [PATCH 124/156] Revert unrelated button and attachment spacing changes, drop the static copy test --- .../HTMLRenderers/AnchorRenderer.tsx | 1 - .../report/ShowPreviousMessagesButton.tsx | 4 +- tests/unit/AskConciergeEmptyStateTest.tsx | 41 ------------------- tests/unit/ShowPreviousMessagesButtonTest.tsx | 4 +- 4 files changed, 3 insertions(+), 47 deletions(-) delete mode 100644 tests/unit/AskConciergeEmptyStateTest.tsx diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx index 562af783cf21..df3252f11630 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx @@ -133,7 +133,6 @@ function AnchorRenderer({tnode, style, key}: AnchorRendererProps) { if (isAttachment && !isVideo) { return ( ({ - useMemoizedLazyIllustrations: () => ({ConciergeBot: 'ConciergeBot'}), -})); - -describe('AskConciergeEmptyState', () => { - beforeAll(async () => { - await IntlStore.load(CONST.LOCALES.EN); - await waitForBatchedUpdates(); - }); - - it('renders the Ask Concierge prompt', () => { - // Given the empty state shown in place of the Concierge chat's messages - // When it renders - render( - - - - - , - ); - - // Then it invites the user to ask Concierge a question - expect(screen.getByTestId('AskConciergeEmptyState')).toBeTruthy(); - expect(screen.getByText('Ask me anything!')).toBeTruthy(); - expect(screen.getByText('Concierge can answer questions, update expenses, and more.')).toBeTruthy(); - }); -}); diff --git a/tests/unit/ShowPreviousMessagesButtonTest.tsx b/tests/unit/ShowPreviousMessagesButtonTest.tsx index ddfd44b7011c..301da70b4d17 100644 --- a/tests/unit/ShowPreviousMessagesButtonTest.tsx +++ b/tests/unit/ShowPreviousMessagesButtonTest.tsx @@ -67,9 +67,9 @@ describe('ShowPreviousMessagesButton', () => { expect(onPress).toHaveBeenCalledTimes(1); }); - it('renders nothing when not in the side panel (main DM)', () => { + it('renders the button when not in the side panel (main DM)', () => { renderButton({}, {isInSidePanel: false}); - expect(screen.queryByRole('button')).toBeNull(); + expect(screen.getByRole('button')).toBeTruthy(); }); it('renders nothing when reportID does not match the Concierge report', () => { From a84b9e13828396871a4c492ffd183d5789512622 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 20:59:24 +0200 Subject: [PATCH 125/156] Test each Concierge welcome state on its own --- .../unit/hooks/useConciergeAskState.test.tsx | 137 +++++++++--------- 1 file changed, 68 insertions(+), 69 deletions(-) diff --git a/tests/unit/hooks/useConciergeAskState.test.tsx b/tests/unit/hooks/useConciergeAskState.test.tsx index dde8cc7f3ec6..50ffa2e4531d 100644 --- a/tests/unit/hooks/useConciergeAskState.test.tsx +++ b/tests/unit/hooks/useConciergeAskState.test.tsx @@ -6,6 +6,7 @@ import useConciergeAskState from '@hooks/useConciergeAskState'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportAction} from '@src/types/onyx'; import Onyx from 'react-native-onyx'; @@ -14,6 +15,9 @@ import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; const wrapper = ({children}: {children: React.ReactNode}) => {children}; const CONCIERGE_REPORT_ID = '1'; +const CURRENT_USER_ACCOUNT_ID = 100; + +let mockShowFullHistory = false; jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ __esModule: true, @@ -21,9 +25,23 @@ jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ })); jest.mock('@pages/inbox/ConciergeChatHistoryContext', () => ({ - useConciergeChatHistoryState: () => ({showFullHistory: false}), + useConciergeChatHistoryState: () => ({showFullHistory: mockShowFullHistory}), })); +const createdAction = {reportActionID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, created: '2024-06-01 10:00:00.000'} as ReportAction; +const conciergeMessage = { + reportActionID: '2', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: CONST.ACCOUNT_ID.CONCIERGE, + created: '2024-06-01 11:00:00.000', +} as ReportAction; +const userMessage = {reportActionID: '3', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, actorAccountID: CURRENT_USER_ACCOUNT_ID, created: '2024-06-01 11:05:00.000'} as ReportAction; + +async function setReportActions(actions: ReportAction[]) { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, Object.fromEntries(actions.map((action) => [action.reportActionID, action]))); + await waitForBatchedUpdates(); +} + describe('useConciergeAskState', () => { beforeAll(async () => { Onyx.init({keys: ONYXKEYS}); @@ -31,100 +49,81 @@ describe('useConciergeAskState', () => { await waitForBatchedUpdates(); }); - it('shows the empty state once the user has messaged Concierge', async () => { - const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); + afterEach(async () => { + mockShowFullHistory = false; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, null); + await waitForBatchedUpdates(); + }); - expect(result.current.isHistoryExpanded).toBe(false); - expect(result.current.shouldShowWelcome).toBe(true); + it('shows the welcome screen once the user has messaged Concierge', async () => { + // Given a fully loaded chat where the user asked Concierge before + await setReportActions([createdAction, conciergeMessage, userMessage]); - // Given the newest page of earlier Concierge messages, with older pages not loaded yet - const conciergeMessage = { - reportActionID: '5', - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - actorAccountID: CONST.ACCOUNT_ID.CONCIERGE, - created: '2024-06-01 11:00:00.000', - }; - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[conciergeMessage.reportActionID]: conciergeMessage}); - await waitForBatchedUpdates(); + // When the Concierge chat renders + const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); - // Then the empty state stays, because the user may have replied on an older page + // Then the welcome screen hides the earlier conversation behind `View chat history` expect(result.current.shouldShowWelcome).toBe(true); + expect(result.current.isHistoryExpanded).toBe(false); + }); - // When the whole chat is loaded and the user never replied, such as the onboarding messages on a new account - const createdAction = { - reportActionID: '7', - actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, - created: '2024-06-01 10:00:00.000', - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[createdAction.reportActionID]: createdAction}); - await waitForBatchedUpdates(); + it('keeps the messages on screen when the user never messaged Concierge', async () => { + // Given a fully loaded chat with only Concierge's messages, such as onboarding on a new account + await setReportActions([createdAction, conciergeMessage]); + + // When the Concierge chat renders + const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); - // Then the conversation stays on screen, because `View chat history` can't bring it back + // Then the messages stay on screen, because `View chat history` would have nothing of the user's to bring back expect(result.current.shouldShowWelcome).toBe(false); + }); - // When the user has messaged Concierge before - const userMessage = { - reportActionID: '6', - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - actorAccountID: 100, - created: '2024-06-01 11:05:00.000', - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[userMessage.reportActionID]: userMessage}); - await waitForBatchedUpdates(); + it('shows the welcome screen while older pages are not loaded', async () => { + // Given only the newest page, where the user's own messages may be on an older page + await setReportActions([conciergeMessage]); - // Then the empty state hides the earlier conversation behind `View chat history` - expect(result.current.shouldShowWelcome).toBe(true); + // When the Concierge chat renders + const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, null); - await waitForBatchedUpdates(); + // Then the welcome screen shows + expect(result.current.shouldShowWelcome).toBe(true); }); - it('keeps the empty state when a question arrives from any device', async () => { - // Given the user messaged Concierge before, and the whole chat is loaded - const createdAction = { - reportActionID: '7', - actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, - created: '2024-06-01 10:00:00.000', - }; - const earlierMessage = { - reportActionID: '6', - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - actorAccountID: 100, - created: '2024-06-01 11:00:00.000', - }; - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, { - [createdAction.reportActionID]: createdAction, - [earlierMessage.reportActionID]: earlierMessage, - }); - await waitForBatchedUpdates(); + it('keeps the welcome screen when a question arrives from another device', async () => { + // Given the welcome screen is showing + await setReportActions([createdAction, conciergeMessage, userMessage]); const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); - expect(result.current.shouldShowWelcome).toBe(true); // When a question sent from another device arrives - const question = { - reportActionID: '3', - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - actorAccountID: 100, - created: '2024-06-01 12:05:00.000', - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[question.reportActionID]: question}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {'4': {...userMessage, reportActionID: '4', created: '2024-06-01 12:00:00.000'}}); await waitForBatchedUpdates(); - // Then the welcome stays, so this device doesn't flicker + // Then the welcome screen stays, so this device doesn't flicker expect(result.current.shouldShowWelcome).toBe(true); + }); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, null); - await waitForBatchedUpdates(); + it('shows the conversation once the user expands the history', async () => { + // Given the user pressed `View chat history` + mockShowFullHistory = true; + await setReportActions([createdAction, conciergeMessage, userMessage]); + + // When the Concierge chat renders + const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); + + // Then the conversation shows instead of the welcome screen + expect(result.current.shouldShowWelcome).toBe(false); + expect(result.current.isHistoryExpanded).toBe(true); }); it('stays off outside the Concierge report', () => { - // Given a report that isn't the Concierge chat - const reportID = '999'; + // Given the history is expanded and a report that isn't the Concierge chat + mockShowFullHistory = true; // When that report renders - const {result} = renderHook(() => useConciergeAskState(reportID), {wrapper}); + const {result} = renderHook(() => useConciergeAskState('999'), {wrapper}); // Then none of the Ask Concierge screen applies to it expect(result.current.shouldShowWelcome).toBe(false); + expect(result.current.isHistoryExpanded).toBe(false); }); }); From 13c0c1f24d411e321313948b3f58a1a185790857 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 20:59:50 +0200 Subject: [PATCH 126/156] Type the Concierge test report actions without casts --- tests/unit/hooks/useConciergeAskState.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/hooks/useConciergeAskState.test.tsx b/tests/unit/hooks/useConciergeAskState.test.tsx index 50ffa2e4531d..9694074fe4d2 100644 --- a/tests/unit/hooks/useConciergeAskState.test.tsx +++ b/tests/unit/hooks/useConciergeAskState.test.tsx @@ -28,14 +28,14 @@ jest.mock('@pages/inbox/ConciergeChatHistoryContext', () => ({ useConciergeChatHistoryState: () => ({showFullHistory: mockShowFullHistory}), })); -const createdAction = {reportActionID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, created: '2024-06-01 10:00:00.000'} as ReportAction; -const conciergeMessage = { +const createdAction: ReportAction = {reportActionID: '1', actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, created: '2024-06-01 10:00:00.000'}; +const conciergeMessage: ReportAction = { reportActionID: '2', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, actorAccountID: CONST.ACCOUNT_ID.CONCIERGE, created: '2024-06-01 11:00:00.000', -} as ReportAction; -const userMessage = {reportActionID: '3', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, actorAccountID: CURRENT_USER_ACCOUNT_ID, created: '2024-06-01 11:05:00.000'} as ReportAction; +}; +const userMessage: ReportAction = {reportActionID: '3', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, actorAccountID: CURRENT_USER_ACCOUNT_ID, created: '2024-06-01 11:05:00.000'}; async function setReportActions(actions: ReportAction[]) { await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, Object.fromEntries(actions.map((action) => [action.reportActionID, action]))); From 7119b2b35c207fd53a6edfb9a62a69f32b7779e3 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 22:04:11 +0200 Subject: [PATCH 127/156] Use replaceAll for local attachment references --- src/libs/ReportUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 8e63d3f1c105..67c68cd12ee3 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -7586,7 +7586,7 @@ function replaceLocalAttachmentReferences( const isStillUploading = !currentCommentHtml || currentCommentHtml.includes(CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE); if (isStillUploading) { - return draftMarkdown.replace(localAttachmentReferenceRegex, '').trim(); + return draftMarkdown.replaceAll(localAttachmentReferenceRegex, '').trim(); } const attachmentTags = currentCommentHtml.match(/]*>|]*>[\s\S]*?<\/video>|]*data-expensify-source="[^"]*"[^>]*>[\s\S]*?<\/a>/gi) ?? []; @@ -7601,7 +7601,7 @@ function replaceLocalAttachmentReferences( // Matching by position is only safe when the draft kept every attachment. const canMatchByPosition = localReferences.length === syncedAttachmentTags.length; let localReferenceIndex = 0; - return draftMarkdown.replace(localAttachmentReferenceRegex, (match) => { + return draftMarkdown.replaceAll(localAttachmentReferenceRegex, (match) => { const localSource = match.match(/\(([^)]*)\)$/)?.at(1) ?? ''; const attachmentID = attachmentIDByLocalSource[localSource]; const syncedAttachmentTag = From 4960ab598ed95d06f0d9224ccd94b0f255329d41 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 22:04:12 +0200 Subject: [PATCH 128/156] Fix lint and type errors in Concierge tests --- tests/ui/ReportActionsListTest.tsx | 5 ++--- tests/unit/hooks/useConciergeAskState.test.tsx | 3 ++- ...seShouldSuppressConciergeIndicators.test.ts | 18 ++++++++++++++++-- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index bf928880afc7..c086daf6362b 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -27,7 +27,6 @@ import {reportActionsListLoadingStateSelector} from '@src/selectors/ReportMetaDa import type * as OnyxTypes from '@src/types/onyx'; import type * as ReactNavigation from '@react-navigation/native'; -import type * as ReactNative from 'react-native'; import React from 'react'; import Onyx from 'react-native-onyx'; @@ -51,7 +50,7 @@ jest.mock('@react-navigation/native', () => { jest.mock('@hooks/useNetwork', () => jest.fn()); jest.mock('@pages/inbox/report/AskConciergeWelcome', () => { - const {View} = jest.requireActual('react-native'); + const {View} = jest.requireActual('react-native'); return () => ; }); jest.mock('@hooks/useInFlightRequests', () => ({ @@ -1130,7 +1129,7 @@ describe('ReportActionsList (body)', () => { } if (key === `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}` && options?.selector) { const reportActions = Object.fromEntries(oldReportActions.map((action) => [action.reportActionID, action])); - return [(options.selector as (actions: OnyxTypes.ReportActions) => unknown)(reportActions), {status: 'loaded'}]; + return [!!(options.selector as (actions: OnyxTypes.ReportActions) => unknown)(reportActions), {status: 'loaded'}]; } if (key.includes('reportActions')) { return [[], {status: 'loaded'}]; diff --git a/tests/unit/hooks/useConciergeAskState.test.tsx b/tests/unit/hooks/useConciergeAskState.test.tsx index 9694074fe4d2..abdcb9819c62 100644 --- a/tests/unit/hooks/useConciergeAskState.test.tsx +++ b/tests/unit/hooks/useConciergeAskState.test.tsx @@ -95,7 +95,8 @@ describe('useConciergeAskState', () => { const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); // When a question sent from another device arrives - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {'4': {...userMessage, reportActionID: '4', created: '2024-06-01 12:00:00.000'}}); + const newQuestion: ReportAction = {...userMessage, reportActionID: '4', created: '2024-06-01 12:00:00.000'}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, {[newQuestion.reportActionID]: newQuestion}); await waitForBatchedUpdates(); // Then the welcome screen stays, so this device doesn't flicker diff --git a/tests/unit/hooks/useShouldSuppressConciergeIndicators.test.ts b/tests/unit/hooks/useShouldSuppressConciergeIndicators.test.ts index 280431aaac2e..0549b6b6c625 100644 --- a/tests/unit/hooks/useShouldSuppressConciergeIndicators.test.ts +++ b/tests/unit/hooks/useShouldSuppressConciergeIndicators.test.ts @@ -9,6 +9,8 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportAction} from '@src/types/onyx'; +import type {RefObject} from 'react'; + import Onyx from 'react-native-onyx'; import createRandomReportAction from '../../utils/collections/reportActions'; @@ -28,6 +30,18 @@ const CURRENT_USER_ACCOUNT_ID = 1; const OTHER_ACCOUNT_ID = 2; const SESSION_START_TIME = '2026-06-29 10:00:00.000'; +const sidePanelState: ReturnType = { + sessionStartTime: null, + isSidePanelTransitionEnded: true, + isSidePanelHiddenOrLargeScreen: true, + shouldHideSidePanel: true, + shouldHideSidePanelBackdrop: true, + shouldHideHelpButton: false, + shouldHideToolTip: false, + sidePanelOffset: {current: null} as RefObject, + sidePanelTranslateX: {current: null} as RefObject, +}; + function buildAction(overrides: Partial): ReportAction { return { ...createRandomReportAction(1), @@ -51,7 +65,7 @@ describe('useShouldSuppressConciergeIndicators', () => { await waitForBatchedUpdates(); // Safe defaults: outside the side panel with no active session, so the followup-list tests only exercise the flag. mockUseIsInSidePanel.mockReturnValue(false); - mockUseSidePanelState.mockReturnValue({sessionStartTime: null} as ReturnType); + mockUseSidePanelState.mockReturnValue(sidePanelState); mockUseCurrentUserPersonalDetails.mockReturnValue({accountID: CURRENT_USER_ACCOUNT_ID}); }); @@ -145,7 +159,7 @@ describe('useShouldSuppressConciergeIndicators', () => { beforeEach(() => { // An active side panel session. mockUseIsInSidePanel.mockReturnValue(true); - mockUseSidePanelState.mockReturnValue({sessionStartTime: SESSION_START_TIME} as ReturnType); + mockUseSidePanelState.mockReturnValue({...sidePanelState, sessionStartTime: SESSION_START_TIME}); }); it('suppresses indicators in a Concierge chat with no session activity', async () => { From 0ce7202e6d6ceac793d24c30b98aa8083a924c01 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 22:04:13 +0200 Subject: [PATCH 129/156] Render the Concierge thread header with a real parent question --- tests/ui/components/HeaderViewTest.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/ui/components/HeaderViewTest.tsx b/tests/ui/components/HeaderViewTest.tsx index 7adba66b190e..ea5dab8a2db4 100644 --- a/tests/ui/components/HeaderViewTest.tsx +++ b/tests/ui/components/HeaderViewTest.tsx @@ -131,8 +131,22 @@ describe('HeaderView', () => { it('should go back to the Concierge chat when New chat is pressed in a Concierge thread', async () => { // Given the header of a thread that Concierge opened for a question const conciergeReportID = '10'; - const threadReport = {...createRandomReport(11, undefined), parentReportID: conciergeReportID}; + const questionAction: ReportAction = { + reportActionID: '100', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + created: '2024-06-01 10:00:00.000', + actorAccountID: currentUserAccountID, + childReportID: '11', + message: [{type: CONST.REPORT.MESSAGE.TYPE.COMMENT, html: 'How do I submit an expense?', text: 'How do I submit an expense?'}], + }; + const threadReport: Report = { + ...createRandomReport(11, undefined), + type: CONST.REPORT.TYPE.CHAT, + parentReportID: conciergeReportID, + parentReportActionID: questionAction.reportActionID, + }; await Onyx.set(ONYXKEYS.CONCIERGE_REPORT_ID, conciergeReportID); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${conciergeReportID}`, {[questionAction.reportActionID]: questionAction}); await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${threadReport.reportID}`, threadReport); await waitForBatchedUpdates(); navigateSpy = jest.spyOn(NavigationActions, 'navigate').mockImplementation(() => {}); From b11501f289feb2a9d15b8de9f6d279b0f05268ed Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 22:04:14 +0200 Subject: [PATCH 130/156] Mock focus state in the ReportFooter test --- tests/ui/ReportFooterTest.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/ui/ReportFooterTest.tsx b/tests/ui/ReportFooterTest.tsx index dfd05766e4b8..f3d5e8e844a5 100644 --- a/tests/ui/ReportFooterTest.tsx +++ b/tests/ui/ReportFooterTest.tsx @@ -29,6 +29,7 @@ jest.mock('@react-navigation/native', () => { return { ...actualNav, useRoute: jest.fn(), + useIsFocused: () => true, }; }); const mockedUseRoute = jest.mocked(useRoute); From af9e2425e1814e89a76c34d3f50a1ecfd9b7264c Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Tue, 20 Oct 2026 23:37:16 +0200 Subject: [PATCH 131/156] Fix ESLint errors in Concierge tests --- tests/ui/ReportActionsListTest.tsx | 3 ++- .../hooks/useShouldSuppressConciergeIndicators.test.ts | 7 +++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index c086daf6362b..c6f50d751d6d 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -27,6 +27,7 @@ import {reportActionsListLoadingStateSelector} from '@src/selectors/ReportMetaDa import type * as OnyxTypes from '@src/types/onyx'; import type * as ReactNavigation from '@react-navigation/native'; +import type ReactNative from 'react-native'; import React from 'react'; import Onyx from 'react-native-onyx'; @@ -50,7 +51,7 @@ jest.mock('@react-navigation/native', () => { jest.mock('@hooks/useNetwork', () => jest.fn()); jest.mock('@pages/inbox/report/AskConciergeWelcome', () => { - const {View} = jest.requireActual('react-native'); + const {View} = jest.requireActual('react-native'); return () => ; }); jest.mock('@hooks/useInFlightRequests', () => ({ diff --git a/tests/unit/hooks/useShouldSuppressConciergeIndicators.test.ts b/tests/unit/hooks/useShouldSuppressConciergeIndicators.test.ts index 0549b6b6c625..b60d3d29d239 100644 --- a/tests/unit/hooks/useShouldSuppressConciergeIndicators.test.ts +++ b/tests/unit/hooks/useShouldSuppressConciergeIndicators.test.ts @@ -9,8 +9,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportAction} from '@src/types/onyx'; -import type {RefObject} from 'react'; - +import {Animated} from 'react-native'; import Onyx from 'react-native-onyx'; import createRandomReportAction from '../../utils/collections/reportActions'; @@ -38,8 +37,8 @@ const sidePanelState: ReturnType = { shouldHideSidePanelBackdrop: true, shouldHideHelpButton: false, shouldHideToolTip: false, - sidePanelOffset: {current: null} as RefObject, - sidePanelTranslateX: {current: null} as RefObject, + sidePanelOffset: {current: new Animated.Value(0)}, + sidePanelTranslateX: {current: new Animated.Value(0)}, }; function buildAction(overrides: Partial): ReportAction { From e3f03711c967773cbefe610fd10f84c2443e9fee Mon Sep 17 00:00:00 2001 From: "Rushat Gabhane (via MelvinBot)" Date: Wed, 21 Oct 2026 10:02:14 +0000 Subject: [PATCH 132/156] Fix ESLint restricted Animated import in useShouldSuppressConciergeIndicators test Co-authored-by: Rushat Gabhane --- tests/unit/hooks/useShouldSuppressConciergeIndicators.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/hooks/useShouldSuppressConciergeIndicators.test.ts b/tests/unit/hooks/useShouldSuppressConciergeIndicators.test.ts index b60d3d29d239..694a29901ccd 100644 --- a/tests/unit/hooks/useShouldSuppressConciergeIndicators.test.ts +++ b/tests/unit/hooks/useShouldSuppressConciergeIndicators.test.ts @@ -9,6 +9,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportAction} from '@src/types/onyx'; +// eslint-disable-next-line no-restricted-imports -- useSidePanelState types its refs as React Native Animated.Value, so the mock needs the same class. import {Animated} from 'react-native'; import Onyx from 'react-native-onyx'; From 5fa960171062754888bef56ef6891fb1e535d11f Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 21 Oct 2026 16:34:33 +0200 Subject: [PATCH 133/156] Keep unread and failed Concierge messages on screen instead of the welcome --- src/hooks/useConciergeAskState.ts | 21 +++++++++++++-- src/hooks/useReportActionsListModel.ts | 15 ++++++++++- .../unit/hooks/useConciergeAskState.test.tsx | 27 +++++++++++++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/hooks/useConciergeAskState.ts b/src/hooks/useConciergeAskState.ts index 49fab5b60a27..2982ed462350 100644 --- a/src/hooks/useConciergeAskState.ts +++ b/src/hooks/useConciergeAskState.ts @@ -1,9 +1,10 @@ -import {isCreatedAction} from '@libs/ReportActionsUtils'; +import {isCreatedAction, isReportActionUnread} from '@libs/ReportActionsUtils'; import {useConciergeChatHistoryState} from '@pages/inbox/ConciergeChatHistoryContext'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportActions} from '@src/types/onyx/ReportAction'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxEntry} from 'react-native-onyx'; @@ -21,6 +22,9 @@ type ConciergeAskState = { /** Whether the welcome screen and the composer are centered together on the screen */ shouldCenterWelcomeWithComposer: boolean; + + /** Whether the Concierge chat has a message from someone else that the user hasn't read */ + hasUnreadMessageFromOthers: boolean; }; function useConciergeAskState(reportID: string | undefined): ConciergeAskState { @@ -28,6 +32,7 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { const {shouldUseNarrowLayout} = useResponsiveLayout(); const {showFullHistory} = useConciergeChatHistoryState(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const [lastReadTime] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: (report) => report?.lastReadTime}); const shouldKeepMessagesOnScreenSelector = (actions: OnyxEntry) => { if (!isAskConciergeChat || !actions) { @@ -35,6 +40,11 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { } const allActions = Object.values(actions); + // A failed send stays on screen so the user can retry or dismiss it. + if (allActions.some((action) => !isEmptyObject(action.errors))) { + return true; + } + // The user's earlier messages may be on an older page. if (!allActions.some((action) => isCreatedAction(action))) { return false; @@ -47,12 +57,19 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { selector: shouldKeepMessagesOnScreenSelector, }); - const shouldShowWelcome = isAskConciergeChat && !showFullHistory && !shouldKeepMessagesOnScreen; + const hasUnreadMessageFromOthersSelector = (actions: OnyxEntry) => + isAskConciergeChat && !!lastReadTime && Object.values(actions ?? {}).some((action) => action.actorAccountID !== currentUserAccountID && isReportActionUnread(action, lastReadTime)); + const [hasUnreadMessageFromOthers = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + selector: hasUnreadMessageFromOthersSelector, + }); + + const shouldShowWelcome = isAskConciergeChat && !showFullHistory && !shouldKeepMessagesOnScreen && !hasUnreadMessageFromOthers; return { isHistoryExpanded: isAskConciergeChat && showFullHistory, shouldShowWelcome, shouldCenterWelcomeWithComposer: shouldShowWelcome && !shouldUseNarrowLayout, + hasUnreadMessageFromOthers, }; } diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index 51dd43e88d7d..4053af77e374 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -10,6 +10,7 @@ import type SCREENS from '@src/SCREENS'; import {reportActionsListLoadingStateSelector} from '@src/selectors/ReportMetaData'; import {useRoute} from '@react-navigation/native'; +import {useEffect} from 'react'; import useConciergeAskState from './useConciergeAskState'; import {useIsAppLoadPending} from './useInFlightRequests'; @@ -63,7 +64,19 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const {showFullHistory: conciergeShowFullHistory} = useConciergeChatHistoryState(); const {setShowFullHistory: setConciergeShowFullHistory} = useConciergeChatHistoryActions(); - const {shouldShowWelcome: shouldShowConciergeWelcome, isHistoryExpanded: isConciergeHistoryExpanded} = useConciergeAskState(reportID); + const { + shouldShowWelcome: shouldShowConciergeWelcome, + isHistoryExpanded: isConciergeHistoryExpanded, + hasUnreadMessageFromOthers: hasUnreadConciergeMessageFromOthers, + } = useConciergeAskState(reportID); + + // Opening the chat marks the unread message as read, so expand the history to keep it on screen afterwards. + useEffect(() => { + if (!hasUnreadConciergeMessageFromOthers) { + return; + } + setConciergeShowFullHistory(true); + }, [hasUnreadConciergeMessageFromOthers, setConciergeShowFullHistory]); const isReportTransactionThread = isReportTransactionThreadUtil(report); const shouldBeAlignedToTop = shouldReportAlignToTop(report, parentReportAction); diff --git a/tests/unit/hooks/useConciergeAskState.test.tsx b/tests/unit/hooks/useConciergeAskState.test.tsx index abdcb9819c62..e044f67e675d 100644 --- a/tests/unit/hooks/useConciergeAskState.test.tsx +++ b/tests/unit/hooks/useConciergeAskState.test.tsx @@ -52,6 +52,7 @@ describe('useConciergeAskState', () => { afterEach(async () => { mockShowFullHistory = false; await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, null); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${CONCIERGE_REPORT_ID}`, null); await waitForBatchedUpdates(); }); @@ -103,6 +104,32 @@ describe('useConciergeAskState', () => { expect(result.current.shouldShowWelcome).toBe(true); }); + it('keeps the messages on screen while Concierge has an unread message', async () => { + // Given the user asked Concierge before and has not read Concierge's newest message + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${CONCIERGE_REPORT_ID}`, {reportID: CONCIERGE_REPORT_ID, lastReadTime: '2024-06-01 11:30:00.000'}); + const unreadConciergeMessage: ReportAction = {...conciergeMessage, reportActionID: '4', created: '2024-06-01 12:00:00.000'}; + await setReportActions([createdAction, conciergeMessage, userMessage, unreadConciergeMessage]); + + // When the Concierge chat renders + const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); + + // Then the unread message shows instead of the welcome screen + expect(result.current.shouldShowWelcome).toBe(false); + expect(result.current.hasUnreadMessageFromOthers).toBe(true); + }); + + it('keeps the messages on screen when a message failed to send', async () => { + // Given the user's newest message failed to send + const failedMessage: ReportAction = {...userMessage, reportActionID: '4', created: '2024-06-01 12:00:00.000', errors: {1: 'Failed to send'}}; + await setReportActions([createdAction, conciergeMessage, userMessage, failedMessage]); + + // When the Concierge chat renders + const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); + + // Then the failed message shows so the user can retry or dismiss it + expect(result.current.shouldShowWelcome).toBe(false); + }); + it('shows the conversation once the user expands the history', async () => { // Given the user pressed `View chat history` mockShowFullHistory = true; From c3c08be12d602adabc7f5f52b250b4fe24b09429 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 21 Oct 2026 16:34:55 +0200 Subject: [PATCH 134/156] Show a task sent from Search in the expanded Concierge chat history --- src/components/Search/SearchRouter/useAskConcierge.tsx | 6 ++++++ tests/unit/hooks/useAskConcierge.test.tsx | 10 ++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/components/Search/SearchRouter/useAskConcierge.tsx b/src/components/Search/SearchRouter/useAskConcierge.tsx index 0e0b0a217fc3..24bf63de801a 100644 --- a/src/components/Search/SearchRouter/useAskConcierge.tsx +++ b/src/components/Search/SearchRouter/useAskConcierge.tsx @@ -7,6 +7,8 @@ import useSidePanelReportID from '@hooks/useSidePanelReportID'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {generateReportID} from '@libs/ReportUtils'; +import {useConciergeChatHistoryActions} from '@pages/inbox/ConciergeChatHistoryContext'; + import {addAttachmentWithComment, addComment} from '@userActions/Report'; import {createTaskFromMarkdown} from '@userActions/Task'; @@ -35,6 +37,7 @@ function useAskConcierge({forceConcierge = false}: {forceConcierge?: boolean} = const {timezone, accountID: currentUserAccountID} = currentUserPersonalDetails; const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); const delegateAccountID = useDelegateAccountID(); + const {setShowFullHistory} = useConciergeChatHistoryActions(); const shouldShowAskConcierge = !!targetReportID && !!targetReport; const shouldRespondInThread = targetReportID === conciergeReportID; @@ -46,6 +49,9 @@ function useAskConcierge({forceConcierge = false}: {forceConcierge?: boolean} = } const isTask = createTaskFromMarkdown({text: trimmedQuery, parentReport: targetReport, currentUserPersonalDetails, quickAction, delegateAccountID}); if (isTask) { + if (shouldRespondInThread && !isInSidePanel) { + setShowFullHistory(true); + } openConciergeAnywhere({forceConcierge}); return; } diff --git a/tests/unit/hooks/useAskConcierge.test.tsx b/tests/unit/hooks/useAskConcierge.test.tsx index d146dca62a1c..32df52a43f45 100644 --- a/tests/unit/hooks/useAskConcierge.test.tsx +++ b/tests/unit/hooks/useAskConcierge.test.tsx @@ -20,6 +20,7 @@ const ADMINS_ROOM_REPORT_ID = '200'; let mockSidePanelReportID: string | undefined; let mockIsInSidePanel = false; const mockOpenConciergeAnywhere = jest.fn(); +const mockSetShowFullHistory = jest.fn(); jest.mock('@hooks/useSidePanelReportID', () => ({ __esModule: true, @@ -31,6 +32,10 @@ jest.mock('@hooks/useOpenConciergeAnywhere', () => ({ default: () => ({openConciergeAnywhere: mockOpenConciergeAnywhere, isInSidePanel: mockIsInSidePanel}), })); +jest.mock('@pages/inbox/ConciergeChatHistoryContext', () => ({ + useConciergeChatHistoryActions: () => ({setShowFullHistory: mockSetShowFullHistory}), +})); + jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ __esModule: true, default: () => ({accountID: 1, timezone: {selected: 'Europe/Warsaw'}, login: 'user@domain.com', email: 'user@domain.com', displayName: 'User', avatar: undefined}), @@ -166,9 +171,10 @@ describe('useAskConcierge', () => { // 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 + // Then Concierge is still opened with its history expanded so the task shows, 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(mockSetShowFullHistory).toHaveBeenCalledWith(true); expect(mockCreateTaskFromMarkdown).toHaveBeenCalledWith(expect.objectContaining({text: '[] Buy milk', parentReport: CONCIERGE_REPORT})); expect(mockAddComment).not.toHaveBeenCalled(); }); From b227fc1a50e5edb751fd46cfd6bc3e1646fbcbaf Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 21 Oct 2026 16:34:56 +0200 Subject: [PATCH 135/156] Use typographic apostrophes in the French Concierge placeholders --- src/languages/fr.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/languages/fr.ts b/src/languages/fr.ts index b47e600747aa..2df8f6d9232b 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -518,12 +518,12 @@ const translations: TranslationDeepObject = { composerPlaceholders: { spendLastMonth: 'Combien ai-je dépensé le mois dernier ?', topExpenses: 'Montre-moi mes plus grosses dépenses ce mois-ci', - spendDrivers: "Qu'est-ce qui a fait augmenter mes dépenses le trimestre dernier ?", + spendDrivers: 'Qu’est-ce qui a fait augmenter mes dépenses le trimestre dernier ?', travelSpendChange: 'Comment mes dépenses de voyage ont-elles évolué depuis le mois dernier ?', topSpender: 'Qui a le plus dépensé ce mois-ci ?', - missingCategory: "Quelles dépenses n'ont pas de catégorie ?", + missingCategory: 'Quelles dépenses n’ont pas de catégorie ?', awaitingApproval: 'Quels rapports attendent mon approbation ?', - notReimbursed: "Qu'est-ce qui ne m'a pas encore été remboursé ?", + notReimbursed: 'Qu’est-ce qui ne m’a pas encore été remboursé ?', unsubmitted: 'Lesquelles de mes dépenses ne sont pas encore soumises ?', leftToPay: 'Que reste-t-il à payer ce mois-ci ?', createExpense: 'Crée une dépense de 30 $ pour un déjeuner', From b1df0abbdd8e39ec06e5bbfcdcc05f2aed53a2cc Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 21 Oct 2026 16:35:38 +0200 Subject: [PATCH 136/156] Add Given/When/Then comments to the several-attachment edit tests --- tests/unit/ReportUtilsTest.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 157acfcca3b1..bfdb9e97a0f5 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -7860,11 +7860,16 @@ describe('ReportUtils', () => { // Given a draft that keeps the second image but not the first const draft = `Hello edited\n\n!(${secondSource})`; + // When the removed uploads are looked up + const removedAttachmentIDs = getUploadingAttachmentIDsRemovedFromDraft(draft, uploadingImagesHtml); + // Then only the first image counts as removed - expect(getUploadingAttachmentIDsRemovedFromDraft(draft, uploadingImagesHtml)).toEqual(['11']); + expect(removedAttachmentIDs).toEqual(['11']); }); it('keeps showing every attachment the edit kept', () => { + // Given a message with two uploading images + // When the first image is removed const html = getUploadingAttachmentHtmlFromComment(uploadingImagesHtml, ['11']); @@ -7874,8 +7879,12 @@ describe('ReportUtils', () => { }); it('re-appends all the attachments when none were removed', () => { + // Given a message with two uploading images + + // When the edit keeps both images const html = getUploadingAttachmentHtmlFromComment(uploadingImagesHtml); + // Then both images are re-appended to the edited message expect(html).toContain(firstSource); expect(html).toContain(secondSource); }); From 41971d5b1b00264b5ad3e6cc782f5a0fa708048e Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 21 Oct 2026 16:35:39 +0200 Subject: [PATCH 137/156] Test that every attachment of an offline message is read on native --- .../libs/prepareRequestPayloadNativeTest.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/unit/libs/prepareRequestPayloadNativeTest.ts b/tests/unit/libs/prepareRequestPayloadNativeTest.ts index c9fab238f83e..a9af71c455ef 100644 --- a/tests/unit/libs/prepareRequestPayloadNativeTest.ts +++ b/tests/unit/libs/prepareRequestPayloadNativeTest.ts @@ -1,3 +1,4 @@ +import {readFileAsync} from '@libs/fileDownload/FileUtils'; import type PrepareRequestPayload from '@libs/prepareRequestPayload/types'; type MockFileCheckResult = {exists: boolean; error?: {message: string; code?: string}}; @@ -159,6 +160,24 @@ describe('prepareRequestPayload (native)', () => { expect(formData.has('undefinedField')).toBe(false); }); + it('should read every attachment of a message sent offline', async () => { + // Given a message with two attachments queued while offline + const mockReadFileAsync = jest.mocked(readFileAsync); + mockReadFileAsync.mockImplementation((source, fileName) => Promise.resolve({name: fileName, uri: source} as unknown as File)); + const firstAttachment = {source: 'file:///tmp/first.jpg', uri: 'file:///tmp/first.jpg', name: 'first.jpg', type: 'image/jpeg'}; + const secondAttachment = {source: 'file:///tmp/second.jpg', uri: 'file:///tmp/second.jpg', name: 'second.jpg', type: 'image/jpeg'}; + + // When the queued request is sent + const formData = await prepareRequestPayload('AddAttachment', {file: firstAttachment, file_1: secondAttachment}, true); + + // Then both attachments are read from disk and sent + expect(mockReadFileAsync).toHaveBeenCalledWith(firstAttachment.source, 'first.jpg', expect.any(Function), undefined, 'image/jpeg'); + expect(mockReadFileAsync).toHaveBeenCalledWith(secondAttachment.source, 'second.jpg', expect.any(Function), undefined, 'image/jpeg'); + expect(formData.has('file')).toBe(true); + expect(formData.has('file_1')).toBe(true); + mockReadFileAsync.mockImplementation(() => Promise.resolve(null)); + }); + describe('a receipt sent as a file object without a source, as a request queued by an older app version sends it', () => { /** `clearAllMocks` keeps a `mockReturnValue`, so a deferred one would leak into the next test. */ beforeEach(() => { From c1886cad51bb3a4066489ec9e9744030709e417e Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 21 Oct 2026 16:37:48 +0200 Subject: [PATCH 138/156] Show the Concierge chat when opened from a link to a message --- src/hooks/useConciergeAskState.ts | 11 ++++++++-- src/pages/inbox/report/ReportActionsList.tsx | 8 ++++++- .../unit/hooks/useConciergeAskState.test.tsx | 22 +++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/hooks/useConciergeAskState.ts b/src/hooks/useConciergeAskState.ts index 2982ed462350..18af7706f9f7 100644 --- a/src/hooks/useConciergeAskState.ts +++ b/src/hooks/useConciergeAskState.ts @@ -1,13 +1,18 @@ +import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; import {isCreatedAction, isReportActionUnread} from '@libs/ReportActionsUtils'; import {useConciergeChatHistoryState} from '@pages/inbox/ConciergeChatHistoryContext'; import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; import type {ReportActions} from '@src/types/onyx/ReportAction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxEntry} from 'react-native-onyx'; +import {useRoute} from '@react-navigation/native'; + import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useIsAskConciergeChat from './useIsAskConciergeChat'; import useOnyx from './useOnyx'; @@ -31,6 +36,7 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { const isAskConciergeChat = useIsAskConciergeChat(reportID); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {showFullHistory} = useConciergeChatHistoryState(); + const route = useRoute>(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const [lastReadTime] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: (report) => report?.lastReadTime}); @@ -63,10 +69,11 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { selector: hasUnreadMessageFromOthersSelector, }); - const shouldShowWelcome = isAskConciergeChat && !showFullHistory && !shouldKeepMessagesOnScreen && !hasUnreadMessageFromOthers; + const isHistoryExpanded = isAskConciergeChat && (showFullHistory || !!route.params?.reportActionID); + const shouldShowWelcome = isAskConciergeChat && !isHistoryExpanded && !shouldKeepMessagesOnScreen && !hasUnreadMessageFromOthers; return { - isHistoryExpanded: isAskConciergeChat && showFullHistory, + isHistoryExpanded, shouldShowWelcome, shouldCenterWelcomeWithComposer: shouldShowWelcome && !shouldUseNarrowLayout, hasUnreadMessageFromOthers, diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 08975589032f..e5811027da83 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -24,6 +24,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; +import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import { @@ -511,7 +512,12 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct {isConciergeHistoryExpanded && !!hasPreviousMessages && ( setConciergeShowFullHistory(false)} + onPress={() => { + setConciergeShowFullHistory(false); + if (reportActionIDFromRoute) { + Navigation.setParams({reportActionID: ''}, route.key); + } + }} /> )} ({ + ...jest.requireActual('@react-navigation/native'), + useRoute: () => ({key: 'Report-test', name: 'Report', params: mockRouteParams}), +})); jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ __esModule: true, @@ -51,6 +59,7 @@ describe('useConciergeAskState', () => { afterEach(async () => { mockShowFullHistory = false; + mockRouteParams = {}; await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}`, null); await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${CONCIERGE_REPORT_ID}`, null); await waitForBatchedUpdates(); @@ -143,6 +152,19 @@ describe('useConciergeAskState', () => { expect(result.current.isHistoryExpanded).toBe(true); }); + it('shows the conversation when opened from a link to a message', async () => { + // Given the user asked Concierge before + await setReportActions([createdAction, conciergeMessage, userMessage]); + + // When the Concierge chat opens from a link to Concierge's message + mockRouteParams = {reportActionID: conciergeMessage.reportActionID}; + const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); + + // Then the linked message shows instead of the welcome screen + expect(result.current.shouldShowWelcome).toBe(false); + expect(result.current.isHistoryExpanded).toBe(true); + }); + it('stays off outside the Concierge report', () => { // Given the history is expanded and a report that isn't the Concierge chat mockShowFullHistory = true; From 54b9bfcb4a12a19a09d07fb9dde92914ebca502f Mon Sep 17 00:00:00 2001 From: "Rushat Gabhane (via MelvinBot)" Date: Wed, 21 Oct 2026 15:01:44 +0000 Subject: [PATCH 139/156] Fix typecheck and ESLint errors in Concierge and prepareRequestPayload tests Co-authored-by: Rushat Gabhane --- tests/ui/ReportFooterTest.tsx | 1 + tests/unit/hooks/useConciergeAskState.test.tsx | 2 +- tests/unit/libs/prepareRequestPayloadNativeTest.ts | 5 +++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/ui/ReportFooterTest.tsx b/tests/ui/ReportFooterTest.tsx index f3d5e8e844a5..9a5d3d86c083 100644 --- a/tests/ui/ReportFooterTest.tsx +++ b/tests/ui/ReportFooterTest.tsx @@ -114,6 +114,7 @@ describe('ReportFooter', () => { isHistoryExpanded: true, shouldShowWelcome: false, shouldCenterWelcomeWithComposer: false, + hasUnreadMessageFromOthers: false, }); await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportID: REPORT_ID, type: CONST.REPORT.TYPE.CHAT}); diff --git a/tests/unit/hooks/useConciergeAskState.test.tsx b/tests/unit/hooks/useConciergeAskState.test.tsx index 2bed52d46d44..ec8b4f465b62 100644 --- a/tests/unit/hooks/useConciergeAskState.test.tsx +++ b/tests/unit/hooks/useConciergeAskState.test.tsx @@ -129,7 +129,7 @@ describe('useConciergeAskState', () => { it('keeps the messages on screen when a message failed to send', async () => { // Given the user's newest message failed to send - const failedMessage: ReportAction = {...userMessage, reportActionID: '4', created: '2024-06-01 12:00:00.000', errors: {1: 'Failed to send'}}; + const failedMessage: ReportAction = {...userMessage, reportActionID: '4', created: '2024-06-01 12:00:00.000', errors: {error: 'Failed to send'}}; await setReportActions([createdAction, conciergeMessage, userMessage, failedMessage]); // When the Concierge chat renders diff --git a/tests/unit/libs/prepareRequestPayloadNativeTest.ts b/tests/unit/libs/prepareRequestPayloadNativeTest.ts index a9af71c455ef..4b6a34ac34b2 100644 --- a/tests/unit/libs/prepareRequestPayloadNativeTest.ts +++ b/tests/unit/libs/prepareRequestPayloadNativeTest.ts @@ -163,11 +163,12 @@ describe('prepareRequestPayload (native)', () => { it('should read every attachment of a message sent offline', async () => { // Given a message with two attachments queued while offline const mockReadFileAsync = jest.mocked(readFileAsync); - mockReadFileAsync.mockImplementation((source, fileName) => Promise.resolve({name: fileName, uri: source} as unknown as File)); + mockReadFileAsync.mockImplementation((source, fileName) => Promise.resolve(new File([], fileName))); const firstAttachment = {source: 'file:///tmp/first.jpg', uri: 'file:///tmp/first.jpg', name: 'first.jpg', type: 'image/jpeg'}; const secondAttachment = {source: 'file:///tmp/second.jpg', uri: 'file:///tmp/second.jpg', name: 'second.jpg', type: 'image/jpeg'}; // When the queued request is sent + // eslint-disable-next-line @typescript-eslint/naming-convention -- `file_1` is the API parameter name for the second attachment. const formData = await prepareRequestPayload('AddAttachment', {file: firstAttachment, file_1: secondAttachment}, true); // Then both attachments are read from disk and sent @@ -175,7 +176,7 @@ describe('prepareRequestPayload (native)', () => { expect(mockReadFileAsync).toHaveBeenCalledWith(secondAttachment.source, 'second.jpg', expect.any(Function), undefined, 'image/jpeg'); expect(formData.has('file')).toBe(true); expect(formData.has('file_1')).toBe(true); - mockReadFileAsync.mockImplementation(() => Promise.resolve(null)); + mockReadFileAsync.mockImplementation(() => Promise.resolve()); }); describe('a receipt sent as a file object without a source, as a request queued by an older app version sends it', () => { From f4a72881909ca6782efce3a8e14b12b26a5768ab Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 21 Oct 2026 19:20:09 +0200 Subject: [PATCH 140/156] Hide New chat on tasks under the Concierge chat --- src/pages/inbox/HeaderView.tsx | 2 +- tests/ui/components/HeaderViewTest.tsx | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/pages/inbox/HeaderView.tsx b/src/pages/inbox/HeaderView.tsx index fc479f505791..226c0b7fdaf6 100644 --- a/src/pages/inbox/HeaderView.tsx +++ b/src/pages/inbox/HeaderView.tsx @@ -162,7 +162,6 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const isGroupChat = isGroupChatReportUtils(report) || isDeprecatedGroupDM(report, isReportArchived); const isConciergeChat = isConciergeChatReport(report, conciergeReportID); const {setShowFullHistory: setConciergeShowFullHistory} = useConciergeChatHistoryActions(); - const isConciergeThread = !!conciergeReportID && report?.parentReportID === conciergeReportID; const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [onboarding] = useOnyx(ONYXKEYS.NVP_ONBOARDING); const allParticipants = getParticipantsAccountIDsForDisplay(report, reportMetadata, false, true); @@ -175,6 +174,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const displayNamesWithTooltips = getDisplayNamesWithTooltips(participantPersonalDetails, isMultipleParticipant, localeCompare, formatPhoneNumber, translate, undefined, isSelfDM); const isChatThread = isChatThreadReportUtils(report); + const isConciergeThread = isChatThread && !!conciergeReportID && report?.parentReportID === conciergeReportID; const isChatRoom = isChatRoomReportUtils(report); const isPolicyExpenseChat = isPolicyExpenseChatReportUtils(report); const isTaskReport = isTaskReportReportUtils(report); diff --git a/tests/ui/components/HeaderViewTest.tsx b/tests/ui/components/HeaderViewTest.tsx index ea5dab8a2db4..4e4a9a891b75 100644 --- a/tests/ui/components/HeaderViewTest.tsx +++ b/tests/ui/components/HeaderViewTest.tsx @@ -166,6 +166,13 @@ describe('HeaderView', () => { // Then the user is taken back to the Concierge chat to ask another question expect(navigateSpy).toHaveBeenCalledWith(expect.stringContaining(`r/${conciergeReportID}`)); + + // When the report under the Concierge chat is a task, such as an onboarding task + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${threadReport.reportID}`, {type: CONST.REPORT.TYPE.TASK}); + await waitForBatchedUpdatesWithAct(); + + // Then there is no New chat button, since a task is not a question thread + expect(screen.queryByText(translateLocal('common.concierge.newChat'))).toBeNull(); }); it('should display join button', async () => { From 1259a985e815d06c9d6c3946af20c0724d89b71a Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 21 Oct 2026 19:20:44 +0200 Subject: [PATCH 141/156] Revert "Hide New chat on tasks under the Concierge chat" This reverts commit f4a72881909ca6782efce3a8e14b12b26a5768ab. --- src/pages/inbox/HeaderView.tsx | 2 +- tests/ui/components/HeaderViewTest.tsx | 7 ------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/pages/inbox/HeaderView.tsx b/src/pages/inbox/HeaderView.tsx index 226c0b7fdaf6..fc479f505791 100644 --- a/src/pages/inbox/HeaderView.tsx +++ b/src/pages/inbox/HeaderView.tsx @@ -162,6 +162,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const isGroupChat = isGroupChatReportUtils(report) || isDeprecatedGroupDM(report, isReportArchived); const isConciergeChat = isConciergeChatReport(report, conciergeReportID); const {setShowFullHistory: setConciergeShowFullHistory} = useConciergeChatHistoryActions(); + const isConciergeThread = !!conciergeReportID && report?.parentReportID === conciergeReportID; const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [onboarding] = useOnyx(ONYXKEYS.NVP_ONBOARDING); const allParticipants = getParticipantsAccountIDsForDisplay(report, reportMetadata, false, true); @@ -174,7 +175,6 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const displayNamesWithTooltips = getDisplayNamesWithTooltips(participantPersonalDetails, isMultipleParticipant, localeCompare, formatPhoneNumber, translate, undefined, isSelfDM); const isChatThread = isChatThreadReportUtils(report); - const isConciergeThread = isChatThread && !!conciergeReportID && report?.parentReportID === conciergeReportID; const isChatRoom = isChatRoomReportUtils(report); const isPolicyExpenseChat = isPolicyExpenseChatReportUtils(report); const isTaskReport = isTaskReportReportUtils(report); diff --git a/tests/ui/components/HeaderViewTest.tsx b/tests/ui/components/HeaderViewTest.tsx index 4e4a9a891b75..ea5dab8a2db4 100644 --- a/tests/ui/components/HeaderViewTest.tsx +++ b/tests/ui/components/HeaderViewTest.tsx @@ -166,13 +166,6 @@ describe('HeaderView', () => { // Then the user is taken back to the Concierge chat to ask another question expect(navigateSpy).toHaveBeenCalledWith(expect.stringContaining(`r/${conciergeReportID}`)); - - // When the report under the Concierge chat is a task, such as an onboarding task - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${threadReport.reportID}`, {type: CONST.REPORT.TYPE.TASK}); - await waitForBatchedUpdatesWithAct(); - - // Then there is no New chat button, since a task is not a question thread - expect(screen.queryByText(translateLocal('common.concierge.newChat'))).toBeNull(); }); it('should display join button', async () => { From 1eaf3784d0c8daedc69d8eb28bc49ca0fe6e6515 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 21 Oct 2026 19:21:25 +0200 Subject: [PATCH 142/156] Base the Concierge unread check on the report and mark it read on Hide and New chat --- src/hooks/useConciergeAskState.ts | 10 +++++----- src/pages/inbox/HeaderView.tsx | 3 ++- src/pages/inbox/report/ReportActionsList.tsx | 3 +++ .../inbox/report/ReportActionsListPaddingView.tsx | 9 +++------ tests/unit/ReportActionsListPaddingViewTest.tsx | 2 +- tests/unit/hooks/useConciergeAskState.test.tsx | 10 +++++++--- 6 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src/hooks/useConciergeAskState.ts b/src/hooks/useConciergeAskState.ts index 18af7706f9f7..5572831a5cc8 100644 --- a/src/hooks/useConciergeAskState.ts +++ b/src/hooks/useConciergeAskState.ts @@ -1,11 +1,12 @@ import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; -import {isCreatedAction, isReportActionUnread} from '@libs/ReportActionsUtils'; +import {isCreatedAction} from '@libs/ReportActionsUtils'; import {useConciergeChatHistoryState} from '@pages/inbox/ConciergeChatHistoryContext'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; +import type {Report} from '@src/types/onyx'; import type {ReportActions} from '@src/types/onyx/ReportAction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -38,7 +39,6 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { const {showFullHistory} = useConciergeChatHistoryState(); const route = useRoute>(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); - const [lastReadTime] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: (report) => report?.lastReadTime}); const shouldKeepMessagesOnScreenSelector = (actions: OnyxEntry) => { if (!isAskConciergeChat || !actions) { @@ -63,9 +63,9 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { selector: shouldKeepMessagesOnScreenSelector, }); - const hasUnreadMessageFromOthersSelector = (actions: OnyxEntry) => - isAskConciergeChat && !!lastReadTime && Object.values(actions ?? {}).some((action) => action.actorAccountID !== currentUserAccountID && isReportActionUnread(action, lastReadTime)); - const [hasUnreadMessageFromOthers = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + const hasUnreadMessageFromOthersSelector = (report: OnyxEntry) => + isAskConciergeChat && !!report?.lastReadTime && (report.lastVisibleActionCreated ?? '') > report.lastReadTime && report.lastActorAccountID !== currentUserAccountID; + const [hasUnreadMessageFromOthers = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, { selector: hasUnreadMessageFromOthersSelector, }); diff --git a/src/pages/inbox/HeaderView.tsx b/src/pages/inbox/HeaderView.tsx index fc479f505791..2a2bdd209045 100644 --- a/src/pages/inbox/HeaderView.tsx +++ b/src/pages/inbox/HeaderView.tsx @@ -88,7 +88,7 @@ import {shouldShowDiscountBanner} from '@libs/SubscriptionUtils'; import EarlyDiscountBanner from '@pages/settings/Subscription/CardSection/BillingBanner/EarlyDiscountBanner'; import FreeTrial from '@pages/settings/Subscription/FreeTrial'; -import {joinRoom} from '@userActions/Report'; +import {joinRoom, readNewestAction} from '@userActions/Report'; import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import CONST from '@src/CONST'; @@ -323,6 +323,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const startNewConciergeChat = () => { setConciergeShowFullHistory(false); + readNewestAction(conciergeReportID, true); Navigation.navigate(getReportRouteForCurrentContext({reportID: conciergeReportID})); }; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index e5811027da83..bd6ecd208547 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -59,6 +59,8 @@ import {useActionListContext, useActionListRef} from '@pages/inbox/ActionListCon import {useConciergeChatHistoryActions} from '@pages/inbox/ConciergeChatHistoryContext'; import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; +import {readNewestAction} from '@userActions/Report'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -514,6 +516,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct isHistoryShown onPress={() => { setConciergeShowFullHistory(false); + readNewestAction(reportID, true); if (reportActionIDFromRoute) { Navigation.setParams({reportActionID: ''}, route.key); } diff --git a/src/pages/inbox/report/ReportActionsListPaddingView.tsx b/src/pages/inbox/report/ReportActionsListPaddingView.tsx index 7f1d1e9b3353..c6b03b3624bb 100644 --- a/src/pages/inbox/report/ReportActionsListPaddingView.tsx +++ b/src/pages/inbox/report/ReportActionsListPaddingView.tsx @@ -1,12 +1,10 @@ -import useIsAskConciergeChat from '@hooks/useIsAskConciergeChat'; +import useConciergeAskState from '@hooks/useConciergeAskState'; import useReportRecipientLocalTime from '@hooks/useReportRecipientLocalTime'; import useThemeStyles from '@hooks/useThemeStyles'; import FS from '@libs/Fullstory'; import {canUserPerformWriteAction} from '@libs/ReportUtils'; -import {useConciergeChatHistoryState} from '@pages/inbox/ConciergeChatHistoryContext'; - import type * as OnyxTypes from '@src/types/onyx'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; @@ -26,13 +24,12 @@ function ReportActionsListPaddingView({report, isReportArchived, children}: Repo const reportActionsListFSClass = FS.getChatFSClass(report); const shouldShowComposerForActiveEditDraft = useShouldShowComposerForActiveEditDraft(); - const isAskConciergeChat = useIsAskConciergeChat(report.reportID); - const {showFullHistory: isConciergeHistoryShown} = useConciergeChatHistoryState(); + const {isHistoryExpanded: isConciergeHistoryExpanded} = useConciergeAskState(report.reportID); const hideComposer = !canUserPerformWriteAction(report, isReportArchived) && !shouldShowComposerForActiveEditDraft; return ( {children} diff --git a/tests/unit/ReportActionsListPaddingViewTest.tsx b/tests/unit/ReportActionsListPaddingViewTest.tsx index f8ddfef60402..8e5e6baede08 100644 --- a/tests/unit/ReportActionsListPaddingViewTest.tsx +++ b/tests/unit/ReportActionsListPaddingViewTest.tsx @@ -25,7 +25,7 @@ jest.mock('@libs/ReportUtils', () => ({ canUserPerformWriteAction: jest.fn(), })); -jest.mock('@hooks/useIsAskConciergeChat', () => () => false); +jest.mock('@hooks/useConciergeAskState', () => () => ({isHistoryExpanded: false})); jest.mock('@hooks/useThemeStyles', () => () => ({ flex1: {flex: 1}, diff --git a/tests/unit/hooks/useConciergeAskState.test.tsx b/tests/unit/hooks/useConciergeAskState.test.tsx index ec8b4f465b62..e0be4b96ba08 100644 --- a/tests/unit/hooks/useConciergeAskState.test.tsx +++ b/tests/unit/hooks/useConciergeAskState.test.tsx @@ -115,9 +115,13 @@ describe('useConciergeAskState', () => { it('keeps the messages on screen while Concierge has an unread message', async () => { // Given the user asked Concierge before and has not read Concierge's newest message - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${CONCIERGE_REPORT_ID}`, {reportID: CONCIERGE_REPORT_ID, lastReadTime: '2024-06-01 11:30:00.000'}); - const unreadConciergeMessage: ReportAction = {...conciergeMessage, reportActionID: '4', created: '2024-06-01 12:00:00.000'}; - await setReportActions([createdAction, conciergeMessage, userMessage, unreadConciergeMessage]); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${CONCIERGE_REPORT_ID}`, { + reportID: CONCIERGE_REPORT_ID, + lastReadTime: '2024-06-01 11:30:00.000', + lastVisibleActionCreated: '2024-06-01 12:00:00.000', + lastActorAccountID: CONST.ACCOUNT_ID.CONCIERGE, + }); + await setReportActions([createdAction, conciergeMessage, userMessage]); // When the Concierge chat renders const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); From c5a928fcbe1e72162ce11b8f32c569f07f55b541 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 21 Oct 2026 19:22:06 +0200 Subject: [PATCH 143/156] Match kept attachments by file name when editing after a restart --- src/libs/ReportUtils.ts | 7 ++++++- tests/unit/ReportUtilsTest.ts | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index b3b15ac5575d..2271253b8a8b 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -7644,15 +7644,20 @@ function replaceLocalAttachmentReferences( return draftMarkdown; } + const fileNameAttributeRegex = new RegExp(`${CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE}="([^"]*)"`); + const getSyncedFileName = (tag: string) => tag.match(fileNameAttributeRegex)?.at(1) ?? tag.match(/\balt="([^"]*)"/)?.at(1) ?? tag.match(/>([^<]*)<\/(?:a|video)>$/)?.at(1); + // Matching by position is only safe when the draft kept every attachment. const canMatchByPosition = localReferences.length === syncedAttachmentTags.length; let localReferenceIndex = 0; return draftMarkdown.replaceAll(localAttachmentReferenceRegex, (match) => { const localSource = match.match(/\(([^)]*)\)$/)?.at(1) ?? ''; const attachmentID = attachmentIDByLocalSource[localSource]; + const localFileName = match.match(/\[([^\]]+)\]/)?.at(1) ?? localSource.split('/').pop(); const syncedAttachmentTag = (attachmentID ? syncedAttachmentTags.find((tag) => tag.match(CONST.REGEX.ATTACHMENT.ATTACHMENT_ID)?.at(2) === attachmentID) : undefined) ?? - (canMatchByPosition ? syncedAttachmentTags.at(localReferenceIndex) : undefined); + (canMatchByPosition ? syncedAttachmentTags.at(localReferenceIndex) : undefined) ?? + (localFileName ? syncedAttachmentTags.find((tag) => getSyncedFileName(tag) === localFileName) : undefined); localReferenceIndex++; if (!syncedAttachmentTag) { return ''; diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 16dae63686ab..168a75d10770 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -7873,6 +7873,18 @@ describe('ReportUtils', () => { // Then the second image stays, not the first expect(savedDraft).toBe(`Hello edited\n\n![receipt.jpg](https://www.expensify.com/chat-attachments/${reportActionID}/w_def.jpg)`); }); + + it('keeps the image the user kept by its file name after the app restarted', () => { + // Given the user deleted the first image, and the app restarted so it no longer knows which upload is which + const syncedHtmlWithoutIDs = syncedTwoImagesHtml.replaceAll(/ data-attachment-id="\d+"/g, ''); + const draft = 'Hello edited\n\n!(file:///var/mobile/Documents/receipt.jpg)'; + + // When the edit is saved after the upload synced + const savedDraft = replaceLocalAttachmentReferences(draft, syncedHtmlWithoutIDs, reportActionID, {}); + + // Then the second image stays, matched by its file name + expect(savedDraft).toBe(`Hello edited\n\n![receipt.jpg](https://www.expensify.com/chat-attachments/${reportActionID}/w_def.jpg)`); + }); }); it('swaps a document reference for the synced attachment link', () => { From aae443ee3a7500d8b4e85138341cc646286e2c01 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 21 Oct 2026 22:34:05 +0200 Subject: [PATCH 144/156] Apply the selector to the Concierge report in the ReportActionsList test mock --- tests/ui/ReportActionsListTest.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index c6f50d751d6d..45f739513b1d 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -1136,7 +1136,8 @@ describe('ReportActionsList (body)', () => { return [[], {status: 'loaded'}]; } if (key === `${ONYXKEYS.COLLECTION.REPORT}${CONCIERGE_REPORT_ID}`) { - return [{...mockReport, reportID: CONCIERGE_REPORT_ID}, {status: 'loaded'}]; + const conciergeReport = {...mockReport, reportID: CONCIERGE_REPORT_ID}; + return [options?.selector ? (options.selector as (report: OnyxTypes.Report) => unknown)(conciergeReport) : conciergeReport, {status: 'loaded'}]; } if (key.includes('report')) { return [undefined, {status: 'loaded'}]; From ad56bb3911de93f6a85a473209e1d23b31e2c636 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 21 Oct 2026 23:14:28 +0200 Subject: [PATCH 145/156] Type the Concierge report selector in the ReportActionsList test mock --- tests/ui/ReportActionsListTest.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 45f739513b1d..46e275426244 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -1137,7 +1137,10 @@ describe('ReportActionsList (body)', () => { } if (key === `${ONYXKEYS.COLLECTION.REPORT}${CONCIERGE_REPORT_ID}`) { const conciergeReport = {...mockReport, reportID: CONCIERGE_REPORT_ID}; - return [options?.selector ? (options.selector as (report: OnyxTypes.Report) => unknown)(conciergeReport) : conciergeReport, {status: 'loaded'}]; + return [ + options?.selector ? (options.selector as (report: OnyxTypes.Report) => OnyxTypes.Report | boolean | undefined)(conciergeReport) : conciergeReport, + {status: 'loaded'}, + ]; } if (key.includes('report')) { return [undefined, {status: 'loaded'}]; From 4d2fba0f0c37c76829c55e44b812c6a0ce4b9244 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 21 Oct 2026 23:59:30 +0200 Subject: [PATCH 146/156] Always show the Concierge chat history toggle --- .../inbox/report/AskConciergeWelcome.tsx | 19 +++++++------------ src/pages/inbox/report/ReportActionsList.tsx | 3 +-- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/pages/inbox/report/AskConciergeWelcome.tsx b/src/pages/inbox/report/AskConciergeWelcome.tsx index e2061c9c0191..ef7d0d2fca3b 100644 --- a/src/pages/inbox/report/AskConciergeWelcome.tsx +++ b/src/pages/inbox/report/AskConciergeWelcome.tsx @@ -17,14 +17,11 @@ type AskConciergeWelcomeProps = { /** The ID of the report being displayed */ reportID: string; - /** Whether the chat has earlier messages to show */ - hasPreviousMessages: boolean; - /** Called when the user presses `View chat history` */ onShowPreviousMessages: () => void; }; -function AskConciergeWelcome({reportID, hasPreviousMessages, onShowPreviousMessages}: AskConciergeWelcomeProps) { +function AskConciergeWelcome({reportID, onShowPreviousMessages}: AskConciergeWelcomeProps) { const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); @@ -32,18 +29,16 @@ function AskConciergeWelcome({reportID, hasPreviousMessages, onShowPreviousMessa return ( markOpenReportEnd(reportID, report, {warm: true})} > - {hasPreviousMessages && ( - - )} + ); } diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index bd6ecd208547..80516032cf79 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -511,7 +511,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const listHeaderComponent = ( <> - {isConciergeHistoryExpanded && !!hasPreviousMessages && ( + {isConciergeHistoryExpanded && ( { @@ -566,7 +566,6 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct return ( ); From 2a50da75ab176821c1ee69b749e7dea3a548d513 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 22 Oct 2026 00:08:02 +0200 Subject: [PATCH 147/156] Show the Concierge welcome after the user hides chat history --- src/hooks/useConciergeAskState.ts | 4 ++-- src/pages/inbox/ConciergeChatHistoryContext.tsx | 17 ++++++++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/hooks/useConciergeAskState.ts b/src/hooks/useConciergeAskState.ts index 5572831a5cc8..eae3cb72d30c 100644 --- a/src/hooks/useConciergeAskState.ts +++ b/src/hooks/useConciergeAskState.ts @@ -36,7 +36,7 @@ type ConciergeAskState = { function useConciergeAskState(reportID: string | undefined): ConciergeAskState { const isAskConciergeChat = useIsAskConciergeChat(reportID); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {showFullHistory} = useConciergeChatHistoryState(); + const {showFullHistory, isHistoryCollapsedByUser} = useConciergeChatHistoryState(); const route = useRoute>(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); @@ -70,7 +70,7 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { }); const isHistoryExpanded = isAskConciergeChat && (showFullHistory || !!route.params?.reportActionID); - const shouldShowWelcome = isAskConciergeChat && !isHistoryExpanded && !shouldKeepMessagesOnScreen && !hasUnreadMessageFromOthers; + const shouldShowWelcome = isAskConciergeChat && !isHistoryExpanded && (isHistoryCollapsedByUser || !shouldKeepMessagesOnScreen) && !hasUnreadMessageFromOthers; return { isHistoryExpanded, diff --git a/src/pages/inbox/ConciergeChatHistoryContext.tsx b/src/pages/inbox/ConciergeChatHistoryContext.tsx index e5789d1067c6..be4198183234 100644 --- a/src/pages/inbox/ConciergeChatHistoryContext.tsx +++ b/src/pages/inbox/ConciergeChatHistoryContext.tsx @@ -10,6 +10,9 @@ import React, {createContext, useContext, useState} from 'react'; type ConciergeChatHistoryStateContextType = { /** Whether the main Concierge chat shows its earlier conversation instead of the welcome screen */ showFullHistory: boolean; + + /** Whether the user collapsed the earlier conversation */ + isHistoryCollapsedByUser: boolean; }; type ConciergeChatHistoryActionsContextType = { @@ -18,6 +21,7 @@ type ConciergeChatHistoryActionsContextType = { const ConciergeChatHistoryStateContext = createContext({ showFullHistory: false, + isHistoryCollapsedByUser: false, }); const ConciergeChatHistoryActionsContext = createContext({ @@ -25,17 +29,24 @@ const ConciergeChatHistoryActionsContext = createContext { + setShowFullHistoryState(show); + setIsHistoryCollapsedByUser(!show); + }; // Collapse the history when the user switches accounts. const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const [prevAccountID, setPrevAccountID] = useState(accountID); if (prevAccountID !== accountID) { setPrevAccountID(accountID); - setShowFullHistory(false); + setShowFullHistoryState(false); + setIsHistoryCollapsedByUser(false); } - const stateValue = {showFullHistory}; + const stateValue = {showFullHistory, isHistoryCollapsedByUser}; const actionsValue = {setShowFullHistory}; return ( From 2bb6c1e7a299fb4177e50f98e8e534737907e3f8 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 22 Oct 2026 00:23:59 +0200 Subject: [PATCH 148/156] Fix types after the chat history toggle changes --- src/pages/inbox/report/ReportActionsSkeletonGuard.tsx | 1 - tests/ui/ReportActionsListTest.tsx | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx b/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx index 8eefc5b389b5..757032dde9e2 100644 --- a/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx +++ b/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx @@ -58,7 +58,6 @@ function ReportActionsSkeletonGuard({reportID, children}: ReportActionsSkeletonG return ( ); diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 46e275426244..1aa3577021ea 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -334,7 +334,7 @@ describe('ReportActionsList (body)', () => { dispatchLocalDraftEvent: jest.fn(), revealDraftFromReportAction: jest.fn(), }); - mockUseConciergeChatHistoryState.mockReturnValue({showFullHistory: false}); + mockUseConciergeChatHistoryState.mockReturnValue({showFullHistory: false, isHistoryCollapsedByUser: false}); mockUseConciergeChatHistoryActions.mockReturnValue({setShowFullHistory: jest.fn()}); mockUseOnyx.mockImplementation(getMockOnyxValue); @@ -1112,7 +1112,7 @@ describe('ReportActionsList (body)', () => { mockUseNetwork.mockReturnValue({isOffline: false}); mockUseIsInSidePanel.mockReturnValue(false); mockUseSidePanelState.mockReturnValue(defaultSidePanelState); - mockUseConciergeChatHistoryState.mockReturnValue({showFullHistory}); + mockUseConciergeChatHistoryState.mockReturnValue({showFullHistory, isHistoryCollapsedByUser: false}); mockUseConciergeChatHistoryActions.mockReturnValue({setShowFullHistory: jest.fn()}); mockUseOnyx.mockImplementation((key: string, options) => { From 61da0f83eeeb9e1f44823a4fc498067302bdf7b8 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 22 Oct 2026 01:12:05 +0200 Subject: [PATCH 149/156] Match same-named attachments to different synced attachments --- src/libs/ReportUtils.ts | 17 ++++++++++++----- tests/unit/ReportUtilsTest.ts | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 2271253b8a8b..1d5a06b46399 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -7650,18 +7650,25 @@ function replaceLocalAttachmentReferences( // Matching by position is only safe when the draft kept every attachment. const canMatchByPosition = localReferences.length === syncedAttachmentTags.length; let localReferenceIndex = 0; + const matchedTagIndexes = new Set(); + const findUnmatchedTagIndex = (isMatch: (tag: string) => boolean) => syncedAttachmentTags.findIndex((tag, index) => !matchedTagIndexes.has(index) && isMatch(tag)); return draftMarkdown.replaceAll(localAttachmentReferenceRegex, (match) => { const localSource = match.match(/\(([^)]*)\)$/)?.at(1) ?? ''; const attachmentID = attachmentIDByLocalSource[localSource]; const localFileName = match.match(/\[([^\]]+)\]/)?.at(1) ?? localSource.split('/').pop(); - const syncedAttachmentTag = - (attachmentID ? syncedAttachmentTags.find((tag) => tag.match(CONST.REGEX.ATTACHMENT.ATTACHMENT_ID)?.at(2) === attachmentID) : undefined) ?? - (canMatchByPosition ? syncedAttachmentTags.at(localReferenceIndex) : undefined) ?? - (localFileName ? syncedAttachmentTags.find((tag) => getSyncedFileName(tag) === localFileName) : undefined); + let syncedAttachmentTagIndex = attachmentID ? findUnmatchedTagIndex((tag) => tag.match(CONST.REGEX.ATTACHMENT.ATTACHMENT_ID)?.at(2) === attachmentID) : -1; + if (syncedAttachmentTagIndex === -1 && canMatchByPosition && !matchedTagIndexes.has(localReferenceIndex)) { + syncedAttachmentTagIndex = localReferenceIndex; + } + if (syncedAttachmentTagIndex === -1 && localFileName) { + syncedAttachmentTagIndex = findUnmatchedTagIndex((tag) => getSyncedFileName(tag) === localFileName); + } localReferenceIndex++; - if (!syncedAttachmentTag) { + if (syncedAttachmentTagIndex === -1) { return ''; } + const syncedAttachmentTag = syncedAttachmentTags.at(syncedAttachmentTagIndex) ?? ''; + matchedTagIndexes.add(syncedAttachmentTagIndex); return `${match.match(/^\n*/)?.at(0) ?? ''}${Parser.htmlToMarkdown(syncedAttachmentTag)}`; }); } diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 168a75d10770..aece0817dff6 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -7885,6 +7885,25 @@ describe('ReportUtils', () => { // Then the second image stays, matched by its file name expect(savedDraft).toBe(`Hello edited\n\n![receipt.jpg](https://www.expensify.com/chat-attachments/${reportActionID}/w_def.jpg)`); }); + + it('matches same-named images to different synced attachments', () => { + // Given three synced images with the same file name, and a draft that kept two of them after the app restarted + const syncedSameNameHtml = ['a', 'b', 'c'] + .map( + (name) => + `image.png`, + ) + .join(''); + const draft = 'Hello edited\n\n![image.png](file:///var/mobile/Documents/image.png)\n\n![image.png](file:///var/mobile/Documents/image.png)'; + + // When the edit is saved + const savedDraft = replaceLocalAttachmentReferences(draft, `Hello

${syncedSameNameHtml}`, reportActionID, {}); + + // Then each kept image gets its own synced attachment instead of both getting the first one + expect(savedDraft).toBe( + `Hello edited\n\n![image.png](https://www.expensify.com/chat-attachments/${reportActionID}/w_a.png)\n\n![image.png](https://www.expensify.com/chat-attachments/${reportActionID}/w_b.png)`, + ); + }); }); it('swaps a document reference for the synced attachment link', () => { From 88c74a87becfac8b546aceff5c0f33568f62d8b0 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Thu, 22 Oct 2026 01:23:26 +0200 Subject: [PATCH 150/156] Simplify same-name attachment matching --- src/libs/ReportUtils.ts | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 1d5a06b46399..b81b6d5b0b65 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -7650,25 +7650,22 @@ function replaceLocalAttachmentReferences( // Matching by position is only safe when the draft kept every attachment. const canMatchByPosition = localReferences.length === syncedAttachmentTags.length; let localReferenceIndex = 0; - const matchedTagIndexes = new Set(); - const findUnmatchedTagIndex = (isMatch: (tag: string) => boolean) => syncedAttachmentTags.findIndex((tag, index) => !matchedTagIndexes.has(index) && isMatch(tag)); + const matchedTags = new Set(); + const findUnmatchedTag = (isMatch: (tag: string) => boolean) => syncedAttachmentTags.find((tag) => !matchedTags.has(tag) && isMatch(tag)); return draftMarkdown.replaceAll(localAttachmentReferenceRegex, (match) => { const localSource = match.match(/\(([^)]*)\)$/)?.at(1) ?? ''; const attachmentID = attachmentIDByLocalSource[localSource]; const localFileName = match.match(/\[([^\]]+)\]/)?.at(1) ?? localSource.split('/').pop(); - let syncedAttachmentTagIndex = attachmentID ? findUnmatchedTagIndex((tag) => tag.match(CONST.REGEX.ATTACHMENT.ATTACHMENT_ID)?.at(2) === attachmentID) : -1; - if (syncedAttachmentTagIndex === -1 && canMatchByPosition && !matchedTagIndexes.has(localReferenceIndex)) { - syncedAttachmentTagIndex = localReferenceIndex; - } - if (syncedAttachmentTagIndex === -1 && localFileName) { - syncedAttachmentTagIndex = findUnmatchedTagIndex((tag) => getSyncedFileName(tag) === localFileName); - } + const positionTag = canMatchByPosition ? syncedAttachmentTags.at(localReferenceIndex) : undefined; + const syncedAttachmentTag = + (attachmentID ? findUnmatchedTag((tag) => tag.match(CONST.REGEX.ATTACHMENT.ATTACHMENT_ID)?.at(2) === attachmentID) : undefined) ?? + (positionTag && !matchedTags.has(positionTag) ? positionTag : undefined) ?? + (localFileName ? findUnmatchedTag((tag) => getSyncedFileName(tag) === localFileName) : undefined); localReferenceIndex++; - if (syncedAttachmentTagIndex === -1) { + if (!syncedAttachmentTag) { return ''; } - const syncedAttachmentTag = syncedAttachmentTags.at(syncedAttachmentTagIndex) ?? ''; - matchedTagIndexes.add(syncedAttachmentTagIndex); + matchedTags.add(syncedAttachmentTag); return `${match.match(/^\n*/)?.at(0) ?? ''}${Parser.htmlToMarkdown(syncedAttachmentTag)}`; }); } From 937131db935b77582707239893c4dece59637581 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 23 Oct 2026 15:20:58 +0200 Subject: [PATCH 151/156] Stop exporting getUploadingAttachmentSource --- src/libs/ReportUtils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 296eaafad697..d7be6accfbc6 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -15793,7 +15793,6 @@ export { getUploadingAttachmentHtmlFromComment, buildEditedCommentWithAttachment, getUploadingAttachmentLabelFromDraft, - getUploadingAttachmentSource, applyLabelToUploadingAttachmentHtml, parseMovedTransactionReportIDs, }; From d268b2006a6186fb07f3739f648eb6969d1ccd4d Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 23 Oct 2026 15:21:00 +0200 Subject: [PATCH 152/156] Use the shared readFileAsync mock in the multi-attachment payload test --- tests/unit/libs/prepareRequestPayloadNativeTest.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/unit/libs/prepareRequestPayloadNativeTest.ts b/tests/unit/libs/prepareRequestPayloadNativeTest.ts index a14ac14c422f..821591859402 100644 --- a/tests/unit/libs/prepareRequestPayloadNativeTest.ts +++ b/tests/unit/libs/prepareRequestPayloadNativeTest.ts @@ -1,4 +1,3 @@ -import {readFileAsync} from '@libs/fileDownload/FileUtils'; import type PrepareRequestPayload from '@libs/prepareRequestPayload/types'; type MockFileCheckResult = {exists: boolean; error?: {message: string; code?: string}}; @@ -197,8 +196,7 @@ describe('prepareRequestPayload (native)', () => { it('should read every attachment of a message sent offline', async () => { // Given a message with two attachments queued while offline - const mockReadFileAsync = jest.mocked(readFileAsync); - mockReadFileAsync.mockImplementation((source, fileName) => Promise.resolve(new File([], fileName))); + mockReadFileAsync.mockImplementation((_source, fileName) => Promise.resolve(new File([], String(fileName)))); const firstAttachment = {source: 'file:///tmp/first.jpg', uri: 'file:///tmp/first.jpg', name: 'first.jpg', type: 'image/jpeg'}; const secondAttachment = {source: 'file:///tmp/second.jpg', uri: 'file:///tmp/second.jpg', name: 'second.jpg', type: 'image/jpeg'}; @@ -211,7 +209,7 @@ describe('prepareRequestPayload (native)', () => { expect(mockReadFileAsync).toHaveBeenCalledWith(secondAttachment.source, 'second.jpg', expect.any(Function), undefined, 'image/jpeg'); expect(formData.has('file')).toBe(true); expect(formData.has('file_1')).toBe(true); - mockReadFileAsync.mockImplementation(() => Promise.resolve()); + mockReadFileAsync.mockImplementation(() => Promise.resolve(null)); }); describe('a receipt sent as a file object without a source, as a request queued by an older app version sends it', () => { From 69510943a07ba4ad5913ac507e287f8a3939fa05 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Fri, 23 Oct 2026 15:21:01 +0200 Subject: [PATCH 153/156] Key renamed attachment labels by a named attachment ID in tests --- tests/unit/RequestConflictUtilsTest.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/tests/unit/RequestConflictUtilsTest.ts b/tests/unit/RequestConflictUtilsTest.ts index aa57de8843f2..1e8daea67244 100644 --- a/tests/unit/RequestConflictUtilsTest.ts +++ b/tests/unit/RequestConflictUtilsTest.ts @@ -218,14 +218,15 @@ describe('RequestConflictUtils', () => { it('resolveEditCommentWithNewAddCommentRequest should rename the queued attachment when the edit renamed it', () => { // Given a text-and-attachment request still queued, with the file picked on native as a plain object const reportActionID = '2'; + const attachmentID = '5'; const persistedRequests = [ - {command: 'AddTextAndAttachment', data: {reportActionID, reportComment: 'test', file: {uri: 'blob:local', name: 'data.csv'}, attachmentID: '5'}}, + {command: 'AddTextAndAttachment', data: {reportActionID, reportComment: 'test', file: {uri: 'blob:local', name: 'data.csv'}, attachmentID}}, {command: 'OpenReport'}, ]; const parameters = {reportID: '1', reportActionID, reportComment: 'text edited'}; // When an edit renames the attachment before the request is sent - const result = resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, [], {'5': 'renamed.csv'}); + const result = resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, [], {[attachmentID]: 'renamed.csv'}); // Then the queued request carries the edited text and the file under its new name, since the server names the attachment after the upload expect(result).toEqual({ @@ -234,7 +235,7 @@ describe('RequestConflictUtils', () => { index: 0, request: { command: 'AddTextAndAttachment', - data: {reportID: '1', reportActionID, reportComment: 'text edited', file: {uri: 'blob:local', name: 'renamed.csv'}, attachmentID: '5'}, + data: {reportID: '1', reportActionID, reportComment: 'text edited', file: {uri: 'blob:local', name: 'renamed.csv'}, attachmentID}, }, }, }); @@ -243,14 +244,15 @@ describe('RequestConflictUtils', () => { it('resolveEditCommentWithNewAddCommentRequest should keep the extension when the new name has none', () => { // Given a queued attachment the editor renames to a bare word const reportActionID = '2'; + const attachmentID = '5'; const persistedRequests = [ - {command: 'AddTextAndAttachment', data: {reportActionID, reportComment: 'test', file: {uri: 'blob:local', name: 'data.csv'}, attachmentID: '5'}}, + {command: 'AddTextAndAttachment', data: {reportActionID, reportComment: 'test', file: {uri: 'blob:local', name: 'data.csv'}, attachmentID}}, {command: 'OpenReport'}, ]; const parameters = {reportID: '1', reportActionID, reportComment: 'text edited'}; // When the rename is applied to the queued file - const result = resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, [], {'5': 'report'}); + const result = resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, [], {[attachmentID]: 'report'}); // Then the original extension travels with it, because the server names the stored file after the upload and // a download can only add back an extension the stored copy already has @@ -260,7 +262,7 @@ describe('RequestConflictUtils', () => { index: 0, request: { command: 'AddTextAndAttachment', - data: {reportID: '1', reportActionID, reportComment: 'text edited', file: {uri: 'blob:local', name: 'report.csv'}, attachmentID: '5'}, + data: {reportID: '1', reportActionID, reportComment: 'text edited', file: {uri: 'blob:local', name: 'report.csv'}, attachmentID}, }, }, }); @@ -269,13 +271,14 @@ describe('RequestConflictUtils', () => { it('resolveEditCommentWithNewAddCommentRequest should rebuild a renamed File, whose name cannot be reassigned', () => { // Given a queued request whose attachment is a web File, where the name is read-only const reportActionID = '2'; + const attachmentID = '5'; const file = new File(['id,total\n1,2'], 'data.csv', {type: 'text/csv'}); - const queuedRequest = {command: 'AddTextAndAttachment', data: {reportActionID, reportComment: 'test', file, attachmentID: '5'}}; + const queuedRequest = {command: 'AddTextAndAttachment', data: {reportActionID, reportComment: 'test', file, attachmentID}}; const persistedRequests = [queuedRequest, {command: 'OpenReport'}]; const parameters = {reportID: '1', reportActionID, reportComment: 'text edited'}; // When an edit renames the attachment before the request is sent - resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, [], {'5': 'renamed.csv'}); + resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, [], {[attachmentID]: 'renamed.csv'}); const renamedFile = queuedRequest.data.file; // Then a new File under the new name replaces it and keeps the original type @@ -287,14 +290,15 @@ describe('RequestConflictUtils', () => { it('resolveEditCommentWithNewAddCommentRequest should keep the native upload properties when it renames a File', () => { // Given a queued attachment shaped the way readFileAsync builds one for the native share flow const reportActionID = '2'; + const attachmentID = '5'; const localUri = 'file:///storage/emulated/0/Android/data/com.expensify.chat/files/Download/Receipts-Upload/data.csv'; const file = Object.assign(new File(['id,total\n1,2'], 'data.csv', {type: 'text/csv'}), {uri: localUri, source: localUri}); - const queuedRequest = {command: 'AddTextAndAttachment', data: {reportActionID, reportComment: 'test', file, attachmentID: '5'}}; + const queuedRequest = {command: 'AddTextAndAttachment', data: {reportActionID, reportComment: 'test', file, attachmentID}}; const persistedRequests = [queuedRequest, {command: 'OpenReport'}]; const parameters = {reportID: '1', reportActionID, reportComment: 'text edited'}; // When an offline edit renames it - resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, [], {'5': 'renamed.csv'}); + resolveEditCommentWithNewAddCommentRequest(persistedRequests, parameters, reportActionID, 0, [], {[attachmentID]: 'renamed.csv'}); const renamedFile = queuedRequest.data.file; // Then the properties the native payload reads to find the file on disk survive the rename From 80afd0a19ce91b5bddec2128933e0a7c848e9876 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Sun, 25 Oct 2026 15:48:38 +0100 Subject: [PATCH 154/156] Show the Concierge welcome to users who never messaged Concierge --- src/hooks/useConciergeAskState.ts | 14 +------------- tests/unit/hooks/useConciergeAskState.test.tsx | 6 +++--- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/src/hooks/useConciergeAskState.ts b/src/hooks/useConciergeAskState.ts index eae3cb72d30c..4fe571e57f54 100644 --- a/src/hooks/useConciergeAskState.ts +++ b/src/hooks/useConciergeAskState.ts @@ -1,6 +1,5 @@ import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; -import {isCreatedAction} from '@libs/ReportActionsUtils'; import {useConciergeChatHistoryState} from '@pages/inbox/ConciergeChatHistoryContext'; @@ -44,20 +43,9 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { if (!isAskConciergeChat || !actions) { return false; } - const allActions = Object.values(actions); // A failed send stays on screen so the user can retry or dismiss it. - if (allActions.some((action) => !isEmptyObject(action.errors))) { - return true; - } - - // The user's earlier messages may be on an older page. - if (!allActions.some((action) => isCreatedAction(action))) { - return false; - } - - const messages = allActions.filter((action) => !isCreatedAction(action)); - return messages.length > 0 && !messages.some((action) => action.actorAccountID === currentUserAccountID); + return Object.values(actions).some((action) => !isEmptyObject(action.errors)); }; const [shouldKeepMessagesOnScreen] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { selector: shouldKeepMessagesOnScreenSelector, diff --git a/tests/unit/hooks/useConciergeAskState.test.tsx b/tests/unit/hooks/useConciergeAskState.test.tsx index e0be4b96ba08..b4062c69df4e 100644 --- a/tests/unit/hooks/useConciergeAskState.test.tsx +++ b/tests/unit/hooks/useConciergeAskState.test.tsx @@ -77,15 +77,15 @@ describe('useConciergeAskState', () => { expect(result.current.isHistoryExpanded).toBe(false); }); - it('keeps the messages on screen when the user never messaged Concierge', async () => { + it('shows the welcome screen when the user never messaged Concierge', async () => { // Given a fully loaded chat with only Concierge's messages, such as onboarding on a new account await setReportActions([createdAction, conciergeMessage]); // When the Concierge chat renders const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); - // Then the messages stay on screen, because `View chat history` would have nothing of the user's to bring back - expect(result.current.shouldShowWelcome).toBe(false); + // Then the welcome screen shows, and `View chat history` brings back Concierge's messages + expect(result.current.shouldShowWelcome).toBe(true); }); it('shows the welcome screen while older pages are not loaded', async () => { From ff1edce0c035b4f5b8bb11785841c7f8923029c1 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Sun, 25 Oct 2026 16:07:54 +0100 Subject: [PATCH 155/156] Fold the Concierge welcome tests into one case --- .../unit/hooks/useConciergeAskState.test.tsx | 30 +++---------------- 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/tests/unit/hooks/useConciergeAskState.test.tsx b/tests/unit/hooks/useConciergeAskState.test.tsx index b4062c69df4e..0c8890878920 100644 --- a/tests/unit/hooks/useConciergeAskState.test.tsx +++ b/tests/unit/hooks/useConciergeAskState.test.tsx @@ -65,38 +65,16 @@ describe('useConciergeAskState', () => { await waitForBatchedUpdates(); }); - it('shows the welcome screen once the user has messaged Concierge', async () => { - // Given a fully loaded chat where the user asked Concierge before - await setReportActions([createdAction, conciergeMessage, userMessage]); - - // When the Concierge chat renders - const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); - - // Then the welcome screen hides the earlier conversation behind `View chat history` - expect(result.current.shouldShowWelcome).toBe(true); - expect(result.current.isHistoryExpanded).toBe(false); - }); - - it('shows the welcome screen when the user never messaged Concierge', async () => { - // Given a fully loaded chat with only Concierge's messages, such as onboarding on a new account + it('shows the welcome screen in place of the conversation', async () => { + // Given a chat with only Concierge's messages, such as onboarding on a new account await setReportActions([createdAction, conciergeMessage]); // When the Concierge chat renders const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); - // Then the welcome screen shows, and `View chat history` brings back Concierge's messages - expect(result.current.shouldShowWelcome).toBe(true); - }); - - it('shows the welcome screen while older pages are not loaded', async () => { - // Given only the newest page, where the user's own messages may be on an older page - await setReportActions([conciergeMessage]); - - // When the Concierge chat renders - const {result} = renderHook(() => useConciergeAskState(CONCIERGE_REPORT_ID), {wrapper}); - - // Then the welcome screen shows + // Then the welcome screen hides the conversation behind `View chat history` expect(result.current.shouldShowWelcome).toBe(true); + expect(result.current.isHistoryExpanded).toBe(false); }); it('keeps the welcome screen when a question arrives from another device', async () => { From 779dd28d5b071b6e13f708ad022b3d7401ee898f Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Sun, 25 Oct 2026 16:25:31 +0100 Subject: [PATCH 156/156] Address Ask Concierge review comments --- src/hooks/useConciergeAskState.ts | 8 ++++---- src/hooks/useReportActionsListModel.ts | 14 ++------------ src/hooks/useReportActionsVisibility.ts | 8 ++------ src/pages/inbox/ConciergeChatHistoryContext.tsx | 14 +++++++++++++- src/pages/inbox/HeaderView.tsx | 7 +++---- src/pages/inbox/report/ReportActionsList.tsx | 7 ++----- .../inbox/report/ReportActionsSkeletonGuard.tsx | 14 +++++++++++++- tests/ui/ReportActionsListTest.tsx | 4 ++-- tests/unit/ReportActionsListContextTest.ts | 1 + 9 files changed, 42 insertions(+), 35 deletions(-) diff --git a/src/hooks/useConciergeAskState.ts b/src/hooks/useConciergeAskState.ts index 4fe571e57f54..1cbed019b8ff 100644 --- a/src/hooks/useConciergeAskState.ts +++ b/src/hooks/useConciergeAskState.ts @@ -39,7 +39,7 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { const route = useRoute>(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); - const shouldKeepMessagesOnScreenSelector = (actions: OnyxEntry) => { + const hasFailedSendSelector = (actions: OnyxEntry) => { if (!isAskConciergeChat || !actions) { return false; } @@ -47,8 +47,8 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { // A failed send stays on screen so the user can retry or dismiss it. return Object.values(actions).some((action) => !isEmptyObject(action.errors)); }; - const [shouldKeepMessagesOnScreen] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { - selector: shouldKeepMessagesOnScreenSelector, + const [hasFailedSend] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + selector: hasFailedSendSelector, }); const hasUnreadMessageFromOthersSelector = (report: OnyxEntry) => @@ -58,7 +58,7 @@ function useConciergeAskState(reportID: string | undefined): ConciergeAskState { }); const isHistoryExpanded = isAskConciergeChat && (showFullHistory || !!route.params?.reportActionID); - const shouldShowWelcome = isAskConciergeChat && !isHistoryExpanded && (isHistoryCollapsedByUser || !shouldKeepMessagesOnScreen) && !hasUnreadMessageFromOthers; + const shouldShowWelcome = isAskConciergeChat && !isHistoryExpanded && (isHistoryCollapsedByUser || !hasFailedSend) && !hasUnreadMessageFromOthers; return { isHistoryExpanded, diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index 4053af77e374..65a9a44d081e 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -3,14 +3,13 @@ import {canUserPerformWriteAction, isReportTransactionThread as isReportTransact import type {ReportsSplitNavigatorParamList} from '@navigation/types'; -import {useConciergeChatHistoryActions, useConciergeChatHistoryState} from '@pages/inbox/ConciergeChatHistoryContext'; +import {useConciergeChatHistoryActions} from '@pages/inbox/ConciergeChatHistoryContext'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import {reportActionsListLoadingStateSelector} from '@src/selectors/ReportMetaData'; import {useRoute} from '@react-navigation/native'; -import {useEffect} from 'react'; import useConciergeAskState from './useConciergeAskState'; import {useIsAppLoadPending} from './useInFlightRequests'; @@ -62,21 +61,12 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const isLoadingOlderReportActions = reportLoadingState?.isLoadingOlderReportActions; const hasLoadingOlderReportActionsError = reportLoadingState?.hasLoadingOlderReportActionsError; - const {showFullHistory: conciergeShowFullHistory} = useConciergeChatHistoryState(); const {setShowFullHistory: setConciergeShowFullHistory} = useConciergeChatHistoryActions(); const { shouldShowWelcome: shouldShowConciergeWelcome, isHistoryExpanded: isConciergeHistoryExpanded, hasUnreadMessageFromOthers: hasUnreadConciergeMessageFromOthers, } = useConciergeAskState(reportID); - - // Opening the chat marks the unread message as read, so expand the history to keep it on screen afterwards. - useEffect(() => { - if (!hasUnreadConciergeMessageFromOthers) { - return; - } - setConciergeShowFullHistory(true); - }, [hasUnreadConciergeMessageFromOthers, setConciergeShowFullHistory]); const isReportTransactionThread = isReportTransactionThreadUtil(report); const shouldBeAlignedToTop = shouldReportAlignToTop(report, parentReportAction); @@ -106,7 +96,6 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea canPerformWriteAction, hasOlderActions, loadOlderChats, - conciergeShowFullHistory, setConciergeShowFullHistory, }); @@ -140,6 +129,7 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea isConciergeHiddenHistory, showConciergeSidePanelWelcome, derivedIsEmptyReport, + hasUnreadConciergeMessageFromOthers, }; // The render state slice on `ReportActionsListStateContext`; this is what drives list re-renders. diff --git a/src/hooks/useReportActionsVisibility.ts b/src/hooks/useReportActionsVisibility.ts index 44f86a7c59ac..0ec24550afc0 100644 --- a/src/hooks/useReportActionsVisibility.ts +++ b/src/hooks/useReportActionsVisibility.ts @@ -24,7 +24,6 @@ type UseReportActionsVisibilityParams = { canPerformWriteAction: boolean; hasOlderActions: boolean; loadOlderChats: (force?: boolean) => void; - conciergeShowFullHistory?: boolean; setConciergeShowFullHistory?: (show: boolean) => void; }; @@ -46,7 +45,6 @@ function useReportActionsVisibility({ canPerformWriteAction, hasOlderActions, loadOlderChats, - conciergeShowFullHistory, setConciergeShowFullHistory, }: UseReportActionsVisibilityParams): UseReportActionsVisibilityResult { const {isOffline} = useNetwork(); @@ -115,8 +113,6 @@ function useReportActionsVisibility({ loadOlderChats, }); - const hasConciergeMainDMHistory = isConciergeMainDM && (hasOlderActions || visibleReportActions.some((action) => !isCreatedAction(action))); - const showConciergeMainDMHistory = () => { setConciergeShowFullHistory?.(true); loadOlderChats(true); @@ -128,8 +124,8 @@ function useReportActionsVisibility({ isConciergeSidePanel, isConciergeHiddenHistory, showConciergeSidePanelWelcome, - showFullHistory: isConciergeMainDM ? !!conciergeShowFullHistory : showFullHistory, - hasPreviousMessages: isConciergeMainDM ? hasConciergeMainDMHistory : hasPreviousMessages, + showFullHistory, + hasPreviousMessages, handleShowPreviousMessages: isConciergeMainDM ? showConciergeMainDMHistory : handleShowPreviousMessages, }; } diff --git a/src/pages/inbox/ConciergeChatHistoryContext.tsx b/src/pages/inbox/ConciergeChatHistoryContext.tsx index be4198183234..a45820dbb846 100644 --- a/src/pages/inbox/ConciergeChatHistoryContext.tsx +++ b/src/pages/inbox/ConciergeChatHistoryContext.tsx @@ -1,5 +1,7 @@ import useOnyx from '@hooks/useOnyx'; +import {readNewestAction} from '@userActions/Report'; + import ONYXKEYS from '@src/ONYXKEYS'; import type {PropsWithChildren} from 'react'; @@ -17,6 +19,9 @@ type ConciergeChatHistoryStateContextType = { type ConciergeChatHistoryActionsContextType = { setShowFullHistory: (show: boolean) => void; + + /** Hides the earlier conversation and marks the Concierge chat as read */ + collapseHistory: () => void; }; const ConciergeChatHistoryStateContext = createContext({ @@ -26,6 +31,7 @@ const ConciergeChatHistoryStateContext = createContext({ setShowFullHistory: () => {}, + collapseHistory: () => {}, }); function ConciergeChatHistoryProvider({children}: PropsWithChildren) { @@ -37,6 +43,12 @@ function ConciergeChatHistoryProvider({children}: PropsWithChildren) { setIsHistoryCollapsedByUser(!show); }; + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const collapseHistory = () => { + setShowFullHistory(false); + readNewestAction(conciergeReportID, true); + }; + // Collapse the history when the user switches accounts. const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const [prevAccountID, setPrevAccountID] = useState(accountID); @@ -47,7 +59,7 @@ function ConciergeChatHistoryProvider({children}: PropsWithChildren) { } const stateValue = {showFullHistory, isHistoryCollapsedByUser}; - const actionsValue = {setShowFullHistory}; + const actionsValue = {setShowFullHistory, collapseHistory}; return ( diff --git a/src/pages/inbox/HeaderView.tsx b/src/pages/inbox/HeaderView.tsx index 2a2bdd209045..d838dfd0337e 100644 --- a/src/pages/inbox/HeaderView.tsx +++ b/src/pages/inbox/HeaderView.tsx @@ -88,7 +88,7 @@ import {shouldShowDiscountBanner} from '@libs/SubscriptionUtils'; import EarlyDiscountBanner from '@pages/settings/Subscription/CardSection/BillingBanner/EarlyDiscountBanner'; import FreeTrial from '@pages/settings/Subscription/FreeTrial'; -import {joinRoom, readNewestAction} from '@userActions/Report'; +import {joinRoom} from '@userActions/Report'; import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import CONST from '@src/CONST'; @@ -161,7 +161,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) const isSelfDM = isSelfDMReportUtils(report); const isGroupChat = isGroupChatReportUtils(report) || isDeprecatedGroupDM(report, isReportArchived); const isConciergeChat = isConciergeChatReport(report, conciergeReportID); - const {setShowFullHistory: setConciergeShowFullHistory} = useConciergeChatHistoryActions(); + const {collapseHistory: collapseConciergeHistory} = useConciergeChatHistoryActions(); const isConciergeThread = !!conciergeReportID && report?.parentReportID === conciergeReportID; const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [onboarding] = useOnyx(ONYXKEYS.NVP_ONBOARDING); @@ -322,8 +322,7 @@ function HeaderView({onNavigationMenuButtonClicked, reportID}: HeaderViewProps) ); const startNewConciergeChat = () => { - setConciergeShowFullHistory(false); - readNewestAction(conciergeReportID, true); + collapseConciergeHistory(); Navigation.navigate(getReportRouteForCurrentContext({reportID: conciergeReportID})); }; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 80516032cf79..10b62b114cba 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -59,8 +59,6 @@ import {useActionListContext, useActionListRef} from '@pages/inbox/ActionListCon import {useConciergeChatHistoryActions} from '@pages/inbox/ConciergeChatHistoryContext'; import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; -import {readNewestAction} from '@userActions/Report'; - import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -153,7 +151,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const route = useRoute>(); const reportActionIDFromRoute = route?.params?.reportActionID; const {sessionStartTime} = useSidePanelState(); - const {setShowFullHistory: setConciergeShowFullHistory} = useConciergeChatHistoryActions(); + const {collapseHistory: collapseConciergeHistory} = useConciergeChatHistoryActions(); const didLayout = useRef(false); @@ -515,8 +513,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct { - setConciergeShowFullHistory(false); - readNewestAction(reportID, true); + collapseConciergeHistory(); if (reportActionIDFromRoute) { Navigation.setParams({reportActionID: ''}, route.key); } diff --git a/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx b/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx index 757032dde9e2..c92e3355a529 100644 --- a/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx +++ b/src/pages/inbox/report/ReportActionsSkeletonGuard.tsx @@ -4,11 +4,13 @@ import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; import usePendingConciergeResponse from '@hooks/usePendingConciergeResponse'; import useReportActionsListModel from '@hooks/useReportActionsListModel'; +import {useConciergeChatHistoryActions} from '@pages/inbox/ConciergeChatHistoryContext'; + import CONST from '@src/CONST'; import type {ReactNode} from 'react'; -import React from 'react'; +import React, {useEffect} from 'react'; import AskConciergeWelcome from './AskConciergeWelcome'; import {computeReportActionsSkeletonState, ReportActionsListActionsContext, ReportActionsListStateContext} from './ReportActionsListContext'; @@ -41,6 +43,16 @@ function ReportActionsSkeletonGuard({reportID, children}: ReportActionsSkeletonG useCopySelectionEvent(); usePendingConciergeResponse(reportID); + // Opening the chat marks the unread message as read, so expand the history to keep it on screen afterwards. + const {setShowFullHistory: setConciergeShowFullHistory} = useConciergeChatHistoryActions(); + const {hasUnreadConciergeMessageFromOthers} = readinessSignals; + useEffect(() => { + if (!hasUnreadConciergeMessageFromOthers) { + return; + } + setConciergeShowFullHistory(true); + }, [hasUnreadConciergeMessageFromOthers, setConciergeShowFullHistory]); + useBackfillWhenNoVisibleActions({ reportID, isMissingReportActions, diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 1aa3577021ea..58ee605d31ca 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -335,7 +335,7 @@ describe('ReportActionsList (body)', () => { revealDraftFromReportAction: jest.fn(), }); mockUseConciergeChatHistoryState.mockReturnValue({showFullHistory: false, isHistoryCollapsedByUser: false}); - mockUseConciergeChatHistoryActions.mockReturnValue({setShowFullHistory: jest.fn()}); + mockUseConciergeChatHistoryActions.mockReturnValue({setShowFullHistory: jest.fn(), collapseHistory: jest.fn()}); mockUseOnyx.mockImplementation(getMockOnyxValue); }); @@ -1113,7 +1113,7 @@ describe('ReportActionsList (body)', () => { mockUseIsInSidePanel.mockReturnValue(false); mockUseSidePanelState.mockReturnValue(defaultSidePanelState); mockUseConciergeChatHistoryState.mockReturnValue({showFullHistory, isHistoryCollapsedByUser: false}); - mockUseConciergeChatHistoryActions.mockReturnValue({setShowFullHistory: jest.fn()}); + mockUseConciergeChatHistoryActions.mockReturnValue({setShowFullHistory: jest.fn(), collapseHistory: jest.fn()}); mockUseOnyx.mockImplementation((key: string, options) => { if (key === ONYXKEYS.CONCIERGE_REPORT_ID) { diff --git a/tests/unit/ReportActionsListContextTest.ts b/tests/unit/ReportActionsListContextTest.ts index b1402c8b730e..fe5b75acacca 100644 --- a/tests/unit/ReportActionsListContextTest.ts +++ b/tests/unit/ReportActionsListContextTest.ts @@ -52,6 +52,7 @@ function createReadinessSignals(overrides: Partial