From cdea8816420a096e39808318d22162f8e05b9fba Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 7 Sep 2026 21:29:47 +0200 Subject: [PATCH 01/98] 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 02/98] 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 03/98] 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 04/98] 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 05/98] 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 06/98] 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 15/98] 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 16/98] 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 17/98] 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 18/98] 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 19/98] 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 20/98] 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 21/98] 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 22/98] 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 23/98] 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 24/98] 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 25/98] 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 26/98] 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 27/98] 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 28/98] 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 29/98] 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 30/98] 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 31/98] 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 32/98] 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 33/98] 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 34/98] 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 35/98] 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 36/98] 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 37/98] 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 38/98] 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 39/98] 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 60/98] 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 61/98] 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 62/98] 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 63/98] 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 64/98] 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 65/98] 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 66/98] 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 67/98] 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 68/98] 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 69/98] 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 70/98] 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 71/98] 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 72/98] 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 73/98] 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 74/98] 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 75/98] 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 76/98] 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 77/98] 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 78/98] 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 79/98] 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 80/98] 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 81/98] 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 82/98] 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 83/98] 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 84/98] 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 85/98] 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 86/98] 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 87/98] 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 88/98] 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 89/98] 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 90/98] 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 91/98] 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 92/98] 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 93/98] 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 94/98] 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 95/98] 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 96/98] 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 97/98] 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 98/98] 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'}, }, ];