From 7fa4d01af7b5623af449ed65e546af93e2afd3d4 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Sat, 11 Jul 2026 04:47:12 +0500 Subject: [PATCH 01/26] Add SuggestedAgentRule type, Onyx key, and agent rule tab constants --- src/CONST/index.ts | 6 ++++++ src/ONYXKEYS.ts | 4 ++++ src/types/onyx/SuggestedAgentRule.ts | 16 ++++++++++++++++ src/types/onyx/index.ts | 2 ++ 4 files changed, 28 insertions(+) create mode 100644 src/types/onyx/SuggestedAgentRule.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 746a7040104f..bdd5b61fb1a2 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5966,6 +5966,11 @@ const CONST = { REQUIRE_FIELDS: 'requireFields', FLAG_FOR_REVIEW: 'flagForReview', }, + AGENT_RULE_TAB_TYPE: 'agentRuleTabType', + AGENT_RULE: { + SUGGESTIONS: 'suggestions', + WRITE: 'write', + }, SPLIT: { AMOUNT: 'amount', PERCENTAGE: 'percentage', @@ -8582,6 +8587,7 @@ const CONST = { SPEND_RULE_RESTRICTION_TYPE: 'WorkspaceRules-SpendRuleRestrictionType', AGENT_RULE_ITEM: 'WorkspaceRules-AgentRuleItem', ADD_AGENT_RULE: 'WorkspaceRules-AddAgentRule', + SUGGESTED_AGENT_RULE: 'WorkspaceRules-SuggestedAgentRule', AGENT_RULE_DELETE: 'WorkspaceRules-AgentRuleDelete', NEW_RULE_MENU_ITEM: 'WorkspaceRules-NewRuleMenuItem', NEW_RULE_MENU_ITEM_RESTRICT_CARD_SPEND: 'WorkspaceRules-NewRuleMenuItem-RestrictCardSpend', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 0e2a82c999e6..915c2a22bccf 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -157,6 +157,9 @@ const ONYXKEYS = { BETAS: 'betas', BETA_CONFIGURATION: 'betaConfiguration', + /** Agent rule suggestions for the add-rule Suggestions tab */ + SUGGESTED_AGENT_RULES: 'suggestedAgentRules', + /** Whether the user is a member of a policy other than their personal */ HAS_NON_PERSONAL_POLICY: 'hasNonPersonalPolicy', @@ -1502,6 +1505,7 @@ type OnyxValuesMapping = { [ONYXKEYS.USER_METADATA]: OnyxTypes.UserMetadata; [ONYXKEYS.STASHED_SESSION]: OnyxTypes.Session; [ONYXKEYS.BETAS]: OnyxTypes.Beta[]; + [ONYXKEYS.SUGGESTED_AGENT_RULES]: OnyxTypes.SuggestedAgentRule[]; [ONYXKEYS.BETA_CONFIGURATION]: OnyxTypes.BetaConfiguration; [ONYXKEYS.NVP_MUTED_PLATFORMS]: Partial>; [ONYXKEYS.NVP_PRIORITY_MODE]: ValueOf; diff --git a/src/types/onyx/SuggestedAgentRule.ts b/src/types/onyx/SuggestedAgentRule.ts new file mode 100644 index 000000000000..8fd3f26b55b9 --- /dev/null +++ b/src/types/onyx/SuggestedAgentRule.ts @@ -0,0 +1,16 @@ +/** + * A ready-made agent rule suggestion delivered from the backend via Onyx. + * Selecting one prefills the add-rule prompt the admin can still edit before saving. + */ +type SuggestedAgentRule = { + /** id for the suggestion */ + id: string; + + /** Display title shown in the Suggestions tab */ + title: string; + + /** Prompt text written into the add-rule form when selected */ + prompt: string; +}; + +export default SuggestedAgentRule; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 6f396e0f8cbf..5eb84d4c1fc7 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -173,6 +173,7 @@ import type ShareTempFile from './ShareTempFile'; import type SidePanel from './SidePanel'; import type {SidePanelContext} from './SidePanel'; import type StripeCustomerID from './StripeCustomerID'; +import type SuggestedAgentRule from './SuggestedAgentRule'; import type SupportalPermissionDenied from './SupportalPermissionDenied'; import type Task from './Task'; import type Transaction from './Transaction'; @@ -423,4 +424,5 @@ export type { LocallyProcessed3DSChallengeReviews, InitiatingBankAccountUnlock, AgentPrompt, + SuggestedAgentRule, }; From 53af052f6fdf7af6c5a25a34b9052298c41cdce2 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Sat, 11 Jul 2026 04:47:34 +0500 Subject: [PATCH 02/26] Add translations for agent rule Suggestions and Edit tabs --- src/languages/de.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 +++++++++- 10 files changed, 90 insertions(+), 10 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 7cc00e700df1..8282792ba7be 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7701,15 +7701,23 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc ruleBotName: 'RuleBot', addRule: 'Agentenregel hinzufügen', findRule: 'Agentenregel finden', - addRuleTitle: 'Regel hinzufügen', + addRuleTitle: 'Neue Regel', editRuleTitle: 'Regel bearbeiten', deleteRule: 'Regel löschen', deleteRuleConfirmation: 'Sind Sie sicher, dass Sie diese Regel löschen möchten?', describeRuleTitle: 'Beschreiben Sie die Regel, der Ihre KI-Agentin/Ihr KI-Agent folgen soll', + describeRuleHeadline: 'Beschreibe deine Regel', + describeRuleSubtitle: 'Beschreibe deine Regel und Concierge erstellt sie', disclaimer: 'KI-Agenten können Fehler machen.', agentCreatedTitle: 'RuleBot wurde zu Ihrem Arbeitsbereich hinzugefügt!', agentCreatedDescription: (agentsRoute: string) => `Um Ihre Agent-Regeln durchzusetzen, haben wir einen Agenten für Sie erstellt und ihn als Administrator zu Ihrem Arbeitsbereich hinzugefügt.

Bearbeiten Sie die Details Ihres Agenten unter Konto > Agenten.
`, + suggestionsTab: 'Vorschläge', + writeTab: 'Bearbeiten', + findSuggestion: 'Regel finden', + getHelpFromConcierge: 'Hilfe von Concierge erhalten', + emptySuggestionsTitle: 'Keine Vorschläge verfügbar', + emptySuggestionsSubtitle: 'Versuchen Sie, Ihre eigene Regel zu schreiben.', }, tabs: { general: 'Allgemein', diff --git a/src/languages/en.ts b/src/languages/en.ts index 610068df24cc..7e74440857a3 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7995,15 +7995,23 @@ const translations = { ruleBotName: 'RuleBot', addRule: 'Add agent rule', findRule: 'Find agent rule', - addRuleTitle: 'Add rule', + addRuleTitle: 'New rule', editRuleTitle: 'Edit rule', deleteRule: 'Delete rule', deleteRuleConfirmation: 'Are you sure you want to delete this rule?', describeRuleTitle: 'Describe the rule for your AI agent to follow', + describeRuleHeadline: 'Describe your rule', + describeRuleSubtitle: 'Describe your rule and Concierge will build it', disclaimer: 'AI agents can make mistakes.', agentCreatedTitle: 'RuleBot has been added to your workspace!', agentCreatedDescription: (agentsRoute: string) => `To enforce your agent rules, we’ve created an agent for you and added it as an admin to your workspace.

Edit your agent’s details in Account > Agents.
`, + suggestionsTab: 'Suggested', + writeTab: 'Edit', + findSuggestion: 'Find a rule', + getHelpFromConcierge: 'Get help from Concierge', + emptySuggestionsTitle: 'No suggestions available', + emptySuggestionsSubtitle: 'Try writing your own rule.', }, }, planTypePage: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 21279550e4c5..8234a103e06c 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7592,8 +7592,10 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, ruleBotName: 'RuleBot', addRule: 'Añadir regla de agente', findRule: 'Encontrar regla de agente', - addRuleTitle: 'Añadir regla', + addRuleTitle: 'Nueva regla', describeRuleTitle: 'Describe la regla que debe seguir tu agente de IA', + describeRuleHeadline: 'Describe tu regla', + describeRuleSubtitle: 'Describe tu regla y Concierge la creará', editRuleTitle: 'Editar regla', deleteRule: 'Eliminar regla', deleteRuleConfirmation: '¿Seguro que quieres eliminar esta regla?', @@ -7601,6 +7603,12 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, agentCreatedTitle: '¡RuleBot se ha añadido a tu espacio de trabajo!', agentCreatedDescription: (agentsRoute: string) => `Para aplicar tus reglas de agente, hemos creado un agente para ti y lo hemos añadido como administrador de tu espacio de trabajo.

Edita los datos de tu agente en Cuenta > Agentes.
`, + suggestionsTab: 'Sugerencias', + writeTab: 'Editar', + findSuggestion: 'Buscar una regla', + getHelpFromConcierge: 'Obtener ayuda de Concierge', + emptySuggestionsTitle: 'No hay sugerencias disponibles', + emptySuggestionsSubtitle: 'Prueba a escribir tu propia regla.', }, tabs: { general: 'General', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 77473d91d780..5e313b46ce01 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7730,15 +7730,23 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e ruleBotName: 'RuleBot', addRule: 'Ajouter une règle d’agent', findRule: 'Rechercher une règle d’agent', - addRuleTitle: 'Ajouter une règle', + addRuleTitle: 'Nouvelle règle', editRuleTitle: 'Modifier la règle', deleteRule: 'Supprimer la règle', deleteRuleConfirmation: 'Voulez-vous vraiment supprimer cette règle ?', describeRuleTitle: 'Décrivez la règle que votre agent IA doit suivre', + describeRuleHeadline: 'Décrivez votre règle', + describeRuleSubtitle: 'Décrivez votre règle et Concierge la créera', disclaimer: 'Les agents IA peuvent faire des erreurs.', agentCreatedTitle: 'RuleBot a été ajouté à votre espace de travail !', agentCreatedDescription: (agentsRoute: string) => `Pour appliquer vos règles d’agent, nous avons créé un agent pour vous et l’avons ajouté comme administrateur de votre espace de travail.

Modifiez les détails de votre agent dans Compte > Agents.
`, + suggestionsTab: 'Suggestions', + writeTab: 'Modifier', + findSuggestion: 'Trouver une règle', + getHelpFromConcierge: 'Obtenir de l’aide de Concierge', + emptySuggestionsTitle: 'Aucune suggestion disponible', + emptySuggestionsSubtitle: 'Essayez d’écrire votre propre règle.', }, tabs: { general: 'Général', diff --git a/src/languages/it.ts b/src/languages/it.ts index 6a3ba4c01f03..54019ce1bb7d 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7684,15 +7684,23 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, ruleBotName: 'RuleBot', addRule: 'Aggiungi regola agente', findRule: 'Trova regola agente', - addRuleTitle: 'Aggiungi regola', + addRuleTitle: 'Nuova regola', editRuleTitle: 'Modifica regola', deleteRule: 'Elimina regola', deleteRuleConfirmation: 'Sei sicuro di voler eliminare questa regola?', describeRuleTitle: 'Descrivi la regola che il tuo agente AI deve seguire', + describeRuleHeadline: 'Descrivi la tua regola', + describeRuleSubtitle: 'Descrivi la tua regola e Concierge la creerà', disclaimer: 'Gli agenti IA possono commettere errori.', agentCreatedTitle: 'RuleBot è stato aggiunto al tuo spazio di lavoro!', agentCreatedDescription: (agentsRoute: string) => `Per applicare le tue regole dell’agente, abbiamo creato un agente per te e lo abbiamo aggiunto come amministratore del tuo spazio di lavoro.

Modifica i dettagli del tuo agente in Account > Agenti.
`, + suggestionsTab: 'Suggerimenti', + writeTab: 'Modifica', + findSuggestion: 'Trova una regola', + getHelpFromConcierge: 'Ottieni aiuto da Concierge', + emptySuggestionsTitle: 'Nessun suggerimento disponibile', + emptySuggestionsSubtitle: 'Prova a scrivere la tua regola.', }, tabs: { general: 'Generale', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index e1a0fa43dadc..d1eca107a495 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7587,15 +7587,23 @@ ${reportName}`, ruleBotName: 'RuleBot', addRule: 'エージェントルールを追加', findRule: 'エージェントルールを検索', - addRuleTitle: 'ルールを追加', + addRuleTitle: '新しいルール', editRuleTitle: 'ルールを編集', deleteRule: 'ルールを削除', deleteRuleConfirmation: 'このルールを削除してもよろしいですか?', describeRuleTitle: 'AI エージェントに従わせるルールを記述してください', + describeRuleHeadline: 'ルールを説明する', + describeRuleSubtitle: 'ルールを説明すると、Conciergeが作成します', disclaimer: 'AI エージェントは間違える場合があります。', agentCreatedTitle: 'RuleBot がワークスペースに追加されました!', agentCreatedDescription: (agentsRoute: string) => `エージェント ルールを適用するために、エージェントを作成し、ワークスペースの管理者として追加しました。

エージェントの詳細は 「アカウント」>「エージェント」 で編集できます。
`, + suggestionsTab: 'おすすめ', + writeTab: '編集', + findSuggestion: 'ルールを探す', + getHelpFromConcierge: 'Conciergeにヘルプを求める', + emptySuggestionsTitle: '利用可能な提案はありません', + emptySuggestionsSubtitle: '独自のルールを作成してみてください。', }, tabs: {general: '一般', cardRestrictions: 'カードの制限', expenseDefaults: '経費のデフォルト設定', requireFields: '必須項目', flagForReview: '確認のためにフラグを付ける'}, bulkActions: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 395a8f4515c8..30964f8b43e1 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7662,15 +7662,23 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, ruleBotName: 'RuleBot', addRule: 'Agentregel toevoegen', findRule: 'Agentregel zoeken', - addRuleTitle: 'Regel toevoegen', + addRuleTitle: 'Nieuwe regel', editRuleTitle: 'Regel bewerken', deleteRule: 'Regel verwijderen', deleteRuleConfirmation: 'Weet je zeker dat je deze regel wilt verwijderen?', describeRuleTitle: 'Beschrijf de regel die je AI-agent moet volgen', + describeRuleHeadline: 'Beschrijf je regel', + describeRuleSubtitle: 'Beschrijf je regel en Concierge bouwt deze', disclaimer: 'AI-agents kunnen fouten maken.', agentCreatedTitle: 'RuleBot is toegevoegd aan je werkruimte!', agentCreatedDescription: (agentsRoute: string) => `Om je agentregels af te dwingen, hebben we een agent voor je gemaakt en deze als beheerder aan je werkruimte toegevoegd.

Bewerk de gegevens van je agent in Account > Agents.
`, + suggestionsTab: 'Suggesties', + writeTab: 'Bewerken', + findSuggestion: 'Zoek een regel', + getHelpFromConcierge: 'Hulp van Concierge krijgen', + emptySuggestionsTitle: 'Geen suggesties beschikbaar', + emptySuggestionsSubtitle: 'Probeer je eigen regel te schrijven.', }, tabs: { general: 'Algemeen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index d435e0c26f66..719ef2794c94 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7641,15 +7641,23 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, ruleBotName: 'RuleBot', addRule: 'Dodaj regułę agenta', findRule: 'Znajdź regułę agenta', - addRuleTitle: 'Dodaj regułę', + addRuleTitle: 'Nowa reguła', editRuleTitle: 'Edytuj regułę', deleteRule: 'Usuń regułę', deleteRuleConfirmation: 'Na pewno chcesz usunąć tę regułę?', describeRuleTitle: 'Opisz zasadę, której ma przestrzegać twój agent AI', + describeRuleHeadline: 'Opisz swoją regułę', + describeRuleSubtitle: 'Opisz swoją regułę, a Concierge ją utworzy', disclaimer: 'Agenci AI mogą popełniać błędy.', agentCreatedTitle: 'RuleBot został dodany do Twojego obszaru roboczego!', agentCreatedDescription: (agentsRoute: string) => `Aby egzekwować Twoje reguły agenta, utworzyliśmy dla Ciebie agenta i dodaliśmy go jako administratora do Twojego obszaru roboczego.

Edytuj dane swojego agenta w sekcji Konto > Agenci.
`, + suggestionsTab: 'Propozycje', + writeTab: 'Edytuj', + findSuggestion: 'Znajdź regułę', + getHelpFromConcierge: 'Uzyskaj pomoc od Concierge', + emptySuggestionsTitle: 'Brak dostępnych propozycji', + emptySuggestionsSubtitle: 'Spróbuj napisać własną regułę.', }, tabs: { general: 'Ogólne', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 39f7c27fbceb..60f1896cd3b2 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7654,15 +7654,23 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, ruleBotName: 'RuleBot', addRule: 'Adicionar regra de agente', findRule: 'Encontrar regra de agente', - addRuleTitle: 'Adicionar regra', + addRuleTitle: 'Nova regra', editRuleTitle: 'Editar regra', deleteRule: 'Excluir regra', deleteRuleConfirmation: 'Tem certeza de que quer excluir esta regra?', describeRuleTitle: 'Descreva a regra que seu agente de IA deve seguir', + describeRuleHeadline: 'Descreva sua regra', + describeRuleSubtitle: 'Descreva sua regra e o Concierge a criará', disclaimer: 'Os agentes de IA podem cometer erros.', agentCreatedTitle: 'O RuleBot foi adicionado ao seu espaço de trabalho!', agentCreatedDescription: (agentsRoute: string) => `Para aplicar suas regras de agente, criamos um agente para você e o adicionamos como administrador do seu espaço de trabalho.

Edite os detalhes do seu agente em Conta > Agentes.
`, + suggestionsTab: 'Sugestões', + writeTab: 'Editar', + findSuggestion: 'Encontrar uma regra', + getHelpFromConcierge: 'Obter ajuda do Concierge', + emptySuggestionsTitle: 'Nenhuma sugestão disponível', + emptySuggestionsSubtitle: 'Tente escrever sua própria regra.', }, tabs: { general: 'Geral', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 9e173a4dc7d8..5b572b81f55d 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7436,15 +7436,23 @@ ${reportName}`, ruleBotName: 'RuleBot', addRule: '添加代理规则', findRule: '查找代理规则', - addRuleTitle: '添加规则', + addRuleTitle: '新规则', editRuleTitle: '编辑规则', deleteRule: '删除规则', deleteRuleConfirmation: '确定要删除此规则吗?', describeRuleTitle: '描述你的 AI 代理需要遵循的规则', + describeRuleHeadline: '描述您的规则', + describeRuleSubtitle: '描述您的规则,Concierge 将为您创建', disclaimer: 'AI 智能体可能会犯错。', agentCreatedTitle: 'RuleBot 已添加到你的工作区!', agentCreatedDescription: (agentsRoute: string) => `为了执行你的代理规则,我们为你创建了一个代理,并将其添加为你工作区的管理员。

你可以在 “账户”>“代理” 中编辑代理的详细信息。
`, + suggestionsTab: '建议', + writeTab: '编辑', + findSuggestion: '查找规则', + getHelpFromConcierge: '向 Concierge 寻求帮助', + emptySuggestionsTitle: '暂无可用建议', + emptySuggestionsSubtitle: '尝试编写您自己的规则。', }, tabs: {general: '常规', cardRestrictions: '卡片限制', expenseDefaults: '报销默认设置', requireFields: '必填字段', flagForReview: '标记以供审核'}, bulkActions: { From adc977d7662d7e46af630d620ff3a136cf8cc521 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Sat, 11 Jul 2026 04:49:00 +0500 Subject: [PATCH 03/26] dd useSuggestedAgentRules hook and suggestion icon mapping --- src/hooks/useSuggestedAgentRules.ts | 29 +++++++++++++++++++++++++++++ src/libs/PolicyRulesUtils.ts | 28 +++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useSuggestedAgentRules.ts diff --git a/src/hooks/useSuggestedAgentRules.ts b/src/hooks/useSuggestedAgentRules.ts new file mode 100644 index 000000000000..c92aab6fc0af --- /dev/null +++ b/src/hooks/useSuggestedAgentRules.ts @@ -0,0 +1,29 @@ +/** + * Reads backend-served suggested agent rules from Onyx for the add-agent-rule Suggestions tab. + */ +import ONYXKEYS from '@src/ONYXKEYS'; +import type SuggestedAgentRule from '@src/types/onyx/SuggestedAgentRule'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +import useOnyx from './useOnyx'; + +type UseSuggestedAgentRulesResult = { + /** Suggested rules from Onyx, or an empty array while loading or when no suggestion exist */ + data: SuggestedAgentRule[]; + + /** Whether the Onyx key is loading */ + isLoading: boolean; +}; + +function useSuggestedAgentRules(): UseSuggestedAgentRulesResult { + const [suggestions, metadata] = useOnyx(ONYXKEYS.SUGGESTED_AGENT_RULES); + const isLoading = isLoadingOnyxValue(metadata); + + return { + data: suggestions ?? [], + isLoading, + }; +} + +export default useSuggestedAgentRules; +export type {UseSuggestedAgentRulesResult}; diff --git a/src/libs/PolicyRulesUtils.ts b/src/libs/PolicyRulesUtils.ts index 440ea0dbdffe..edfdcd89f55e 100644 --- a/src/libs/PolicyRulesUtils.ts +++ b/src/libs/PolicyRulesUtils.ts @@ -1,5 +1,18 @@ import CONST from '@src/CONST'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import type SuggestedAgentRule from '@src/types/onyx/SuggestedAgentRule'; + +type SuggestedAgentRuleIcon = 'ThumbsUp' | 'CircleSlash' | 'Flag' | 'Coins'; + +/** + * Backend suggestions are {id, title, prompt} only, so Suggestions-tab icons are derived from copy. + * Unmatched suggestions fall back to ThumbsUp. + */ +const SUGGESTED_AGENT_RULE_ICON_RULES = [ + {icon: 'CircleSlash', keywords: ['block', 'prohibit', 'reject']}, + {icon: 'Flag', keywords: ['flag']}, + {icon: 'Coins', keywords: ['limit', '/night', 'hotel']}, +] as const satisfies ReadonlyArray<{icon: SuggestedAgentRuleIcon; keywords: readonly string[]}>; function isPendingDeleteOrUpdate(pendingAction: PendingAction | undefined): boolean { return pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE; @@ -9,4 +22,17 @@ function getMccGroupDisplayName(groupID: string): string { return groupID.charAt(0).toUpperCase() + groupID.slice(1); } -export {getMccGroupDisplayName, isPendingDeleteOrUpdate}; +function getSuggestedAgentRuleIcon(suggestion: SuggestedAgentRule): SuggestedAgentRuleIcon { + const text = `${suggestion.title} ${suggestion.prompt}`.toLowerCase(); + + for (const rule of SUGGESTED_AGENT_RULE_ICON_RULES) { + if (rule.keywords.some((keyword) => text.includes(keyword))) { + return rule.icon; + } + } + + return 'ThumbsUp'; +} + +export {getMccGroupDisplayName, getSuggestedAgentRuleIcon, isPendingDeleteOrUpdate}; +export type {SuggestedAgentRuleIcon}; From dc63702d8725ec6653c35f3d3b18eb2938a54710 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Sat, 11 Jul 2026 04:52:36 +0500 Subject: [PATCH 04/26] Add Suggestions and Edit tabs to the new agent rule flow --- .../rules/AgentRules/AddAgentRulePage.tsx | 127 +++++++------ .../AgentRules/AddAgentRuleSuggestionsTab.tsx | 176 ++++++++++++++++++ .../rules/AgentRules/AddAgentRuleWriteTab.tsx | 97 ++++++++++ 3 files changed, 339 insertions(+), 61 deletions(-) create mode 100644 src/pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab.tsx create mode 100644 src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx index ccb4cb021a53..b069b56864e2 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx @@ -1,20 +1,22 @@ -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import type {FormInputErrors, FormOnyxValues, FormRef} from '@components/Form/types'; +import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {BotAvatarBlue} from '@components/Icon/DefaultBotAvatars'; import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; -import Text from '@components/Text'; -import TextInput from '@components/TextInput'; +import TabSelectorBase from '@components/TabSelector/TabSelectorBase'; +import TabSelectorContextProvider from '@components/TabSelector/TabSelectorContext'; +import type {TabSelectorBaseItem} from '@components/TabSelector/types'; import useConfirmModal from '@hooks/useConfirmModal'; -import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; +import {clearDraftValues, setDraftValues} from '@libs/actions/FormActions'; +import Tab from '@libs/actions/Tab'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; @@ -31,15 +33,27 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/AddAgentRuleForm'; +import type SuggestedAgentRule from '@src/types/onyx/SuggestedAgentRule'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {TextInputKeyPressEvent} from 'react-native'; +import type {ValueOf} from 'type-fest'; -import React, {useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; +import type {AddAgentRuleFormID} from './AddAgentRuleWriteTab'; + +import AddAgentRuleSuggestionsTab from './AddAgentRuleSuggestionsTab'; +import AddAgentRuleWriteTab from './AddAgentRuleWriteTab'; + type AddAgentRulePageProps = PlatformStackScreenProps; -type AddAgentRuleFormID = typeof ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM; +type AgentRuleTab = ValueOf; + +const AGENT_RULE_TAB_VALUES = new Set(Object.values(CONST.TAB.AGENT_RULE)); + +function isAgentRuleTab(key: string): key is AgentRuleTab { + return AGENT_RULE_TAB_VALUES.has(key); +} function AddAgentRulePage({ route: { @@ -48,29 +62,36 @@ function AddAgentRulePage({ }: AddAgentRulePageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const shouldUseScrollableLayout = useIsInLandscapeMode(); const {isBetaEnabled} = usePermissions(); const isCustomAgentEnabled = isBetaEnabled(CONST.BETAS.CUSTOM_AGENT); const policy = usePolicy(policyID); - const formRef = useRef(null); const linkPressedRef = useRef(false); const {showConfirmModal, closeModal} = useConfirmModal(); + const [lastSelectedTab] = useOnyx(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.AGENT_RULE_TAB_TYPE}`); + const activeTab: AgentRuleTab = lastSelectedTab && isAgentRuleTab(lastSelectedTab) ? lastSelectedTab : CONST.TAB.AGENT_RULE.SUGGESTIONS; + const tabIcons = useMemoizedLazyExpensifyIcons(['Feed', 'Pencil']); - const handleKeyPress = (e: TextInputKeyPressEvent | KeyboardEvent) => { - if (!('key' in e)) { - return; - } - if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { - formRef.current?.submit(); - } - }; + useEffect(() => { + Tab.setSelectedTab(CONST.TAB.AGENT_RULE_TAB_TYPE, CONST.TAB.AGENT_RULE.SUGGESTIONS); + return () => clearDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM); + }, []); - const validate = (values: FormOnyxValues): FormInputErrors => { - const errors: FormInputErrors = {}; - if (!values[INPUT_IDS.PROMPT].trim()) { - errors[INPUT_IDS.PROMPT] = translate('common.error.fieldRequired'); - } - return errors; + const tabs: TabSelectorBaseItem[] = [ + { + key: CONST.TAB.AGENT_RULE.SUGGESTIONS, + title: translate('workspace.rules.agentRules.suggestionsTab'), + icon: tabIcons.Feed, + }, + { + key: CONST.TAB.AGENT_RULE.WRITE, + title: translate('workspace.rules.agentRules.writeTab'), + icon: tabIcons.Pencil, + }, + ]; + + const selectSuggestion = (suggestion: SuggestedAgentRule) => { + setDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM, {[INPUT_IDS.PROMPT]: suggestion.prompt}); + Tab.setSelectedTab(CONST.TAB.AGENT_RULE_TAB_TYPE, CONST.TAB.AGENT_RULE.WRITE); }; const saveRule = (values: FormOnyxValues): void => { @@ -78,6 +99,7 @@ function AddAgentRulePage({ // an admin. Surface a one-time modal explaining this side effect before navigating back. const isFirstRule = isEmptyObject(policy?.rules?.agentRules); addPolicyAgentRule(policyID, rand64(), values[INPUT_IDS.PROMPT]); + clearDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM); if (!isFirstRule) { Navigation.goBack(); return; @@ -130,44 +152,27 @@ function AddAgentRulePage({ testID="AddAgentRulePage" offlineIndicatorStyle={styles.mtAuto} includeSafeAreaPaddingBottom - shouldEnableMaxHeight={shouldUseScrollableLayout} + shouldEnableMaxHeight > - - - - - - {translate('workspace.rules.agentRules.disclaimer')} - - + + + { + if (!isAgentRuleTab(key)) { + return; + } + Tab.setSelectedTab(CONST.TAB.AGENT_RULE_TAB_TYPE, key); + }} + equalWidth + /> + + + + {activeTab === CONST.TAB.AGENT_RULE.SUGGESTIONS ? : } + ); diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab.tsx new file mode 100644 index 000000000000..362d7d2fb842 --- /dev/null +++ b/src/pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab.tsx @@ -0,0 +1,176 @@ +/** + * Suggestions tab for the add-agent-rule flow. Lists backend-served ready-made rules as + * selectable cards; Next prefills the Edit tab prompt via the parent callback. + */ +import ActivityIndicator from '@components/ActivityIndicator'; +import BlockingView from '@components/BlockingViews/BlockingView'; +import Button from '@components/ButtonComposed'; +import FixedFooter from '@components/FixedFooter'; +import Icon from '@components/Icon'; +import {PressableWithFeedback} from '@components/Pressable'; +import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; + +import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useSuggestedAgentRules from '@hooks/useSuggestedAgentRules'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import Navigation from '@libs/Navigation/Navigation'; +import {getSuggestedAgentRuleIcon} from '@libs/PolicyRulesUtils'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type SuggestedAgentRule from '@src/types/onyx/SuggestedAgentRule'; + +import React, {useMemo, useState} from 'react'; +import {View} from 'react-native'; + +type AddAgentRuleSuggestionsTabProps = { + onSelectSuggestion: (suggestion: SuggestedAgentRule) => void; +}; + +const SUGGESTION_ICON_NAMES = ['ThumbsUp', 'CircleSlash', 'Flag', 'Coins'] as const; + +function AddAgentRuleSuggestionsTab({onSelectSuggestion}: AddAgentRuleSuggestionsTabProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const theme = useTheme(); + const {isOffline} = useNetwork(); + const {data, isLoading} = useSuggestedAgentRules(); + const illustrations = useMemoizedLazyIllustrations(['Lightbulb']); + const icons = useMemoizedLazyExpensifyIcons([...SUGGESTION_ICON_NAMES]); + const [searchValue, setSearchValue] = useState(''); + const [selectedSuggestionID, setSelectedSuggestionID] = useState(); + + const filteredSuggestions = useMemo(() => { + const trimmedSearch = searchValue.trim().toLowerCase(); + if (!trimmedSearch) { + return data; + } + return data.filter((suggestion) => suggestion.title.toLowerCase().includes(trimmedSearch) || suggestion.prompt.toLowerCase().includes(trimmedSearch)); + }, [data, searchValue]); + + const selectedSuggestion = data.find((suggestion) => suggestion.id === selectedSuggestionID); + + const goToConcierge = () => { + Navigation.navigate(ROUTES.CONCIERGE); + }; + + const goToEditWithSelection = () => { + if (!selectedSuggestion) { + return; + } + onSelectSuggestion(selectedSuggestion); + }; + + if (isLoading && data.length === 0) { + return ( + + + + ); + } + + if (!isLoading && data.length === 0) { + return ( + + ); + } + + return ( + + + + + + {filteredSuggestions.length === 0 ? ( + + ) : ( + filteredSuggestions.map((suggestion) => { + const iconName = getSuggestedAgentRuleIcon(suggestion); + const isSelected = suggestion.id === selectedSuggestionID; + return ( + setSelectedSuggestionID(suggestion.id)} + wrapperStyle={[styles.mh5]} + style={[ + styles.flexRow, + styles.alignItemsCenter, + styles.ph5, + styles.pv5, + styles.highlightBG, + styles.borderRadiusComponentNormal, + isSelected && styles.activeComponentBG, + ]} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.SUGGESTED_AGENT_RULE} + > + + {suggestion.title} + + ); + }) + )} + + + + + + + ); +} + +AddAgentRuleSuggestionsTab.displayName = 'AddAgentRuleSuggestionsTab'; + +export default AddAgentRuleSuggestionsTab; diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx new file mode 100644 index 000000000000..26003d51650e --- /dev/null +++ b/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx @@ -0,0 +1,97 @@ +/** + * Write (Edit) tab for the add-agent-rule flow. Owns the free-text prompt form and save path. + */ +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues, FormRef} from '@components/Form/types'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; + +import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/AddAgentRuleForm'; + +import type {TextInputKeyPressEvent} from 'react-native'; + +import React, {useRef} from 'react'; +import {View} from 'react-native'; + +type AddAgentRuleFormID = typeof ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM; + +type AddAgentRuleWriteTabProps = { + onSave: (values: FormOnyxValues) => void; +}; + +function AddAgentRuleWriteTab({onSave}: AddAgentRuleWriteTabProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const shouldUseScrollableLayout = useIsInLandscapeMode(); + const formRef = useRef(null); + + const handleKeyPress = (e: TextInputKeyPressEvent | KeyboardEvent) => { + if (!('key' in e)) { + return; + } + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + formRef.current?.submit(); + } + }; + + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; + if (!values[INPUT_IDS.PROMPT]?.trim()) { + errors[INPUT_IDS.PROMPT] = translate('common.error.fieldRequired'); + } + return errors; + }; + + return ( + + + {translate('workspace.rules.agentRules.describeRuleHeadline')} + {translate('workspace.rules.agentRules.describeRuleSubtitle')} + + + + + + + + + ); +} + +AddAgentRuleWriteTab.displayName = 'AddAgentRuleWriteTab'; + +export default AddAgentRuleWriteTab; +export type {AddAgentRuleFormID}; From 8a845696ee897dd8a91fa949c96a7407f7bd4152 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Sat, 11 Jul 2026 04:55:31 +0500 Subject: [PATCH 05/26] Add tests for suggested agent rules --- tests/ui/AddAgentRuleSuggestionsTabTest.tsx | 160 ++++++++++++++++++++ tests/unit/getSuggestedAgentRuleIconTest.ts | 53 +++++++ 2 files changed, 213 insertions(+) create mode 100644 tests/ui/AddAgentRuleSuggestionsTabTest.tsx create mode 100644 tests/unit/getSuggestedAgentRuleIconTest.ts diff --git a/tests/ui/AddAgentRuleSuggestionsTabTest.tsx b/tests/ui/AddAgentRuleSuggestionsTabTest.tsx new file mode 100644 index 000000000000..14bbf0d74a31 --- /dev/null +++ b/tests/ui/AddAgentRuleSuggestionsTabTest.tsx @@ -0,0 +1,160 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import useSuggestedAgentRules from '@hooks/useSuggestedAgentRules'; + +import Navigation from '@libs/Navigation/Navigation'; + +import AddAgentRuleSuggestionsTab from '@pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab'; + +import ROUTES from '@src/ROUTES'; +import type SuggestedAgentRule from '@src/types/onyx/SuggestedAgentRule'; + +import React from 'react'; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- jest.requireActual returns an untyped module object +const {View: MockView, Pressable: MockPressable, Text: MockText, TextInput: MockTextInput} = jest.requireActual('react-native'); + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: jest.fn(() => ({ + ThumbsUp: 'ThumbsUp', + CircleSlash: 'CircleSlash', + Flag: 'Flag', + Coins: 'Coins', + })), + useMemoizedLazyIllustrations: jest.fn(() => ({Lightbulb: 'Lightbulb'})), +})); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => key, + })), +); +jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); +jest.mock('@hooks/useSuggestedAgentRules'); +jest.mock('@hooks/useTheme', () => jest.fn(() => ({icon: '#000'}))); +jest.mock('@hooks/useThemeStyles', () => + jest.fn( + () => + new Proxy( + {}, + { + get: () => ({}), + }, + ), + ), +); +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), +})); +jest.mock('@components/ActivityIndicator', () => jest.fn(() => null)); +jest.mock('@components/Icon', () => jest.fn(() => null)); +jest.mock('@components/TextInput', () => ({value, onChangeText, label}: {value?: string; onChangeText?: (text: string) => void; label?: string}) => ( + +)); +jest.mock('@components/BlockingViews/BlockingView', () => ({title, subtitle}: {title: string; subtitle?: string}) => ( + <> + {title} + {!!subtitle && {subtitle}} + +)); +jest.mock('@components/ButtonComposed', () => { + function MockButton({children, onPress, isDisabled}: {children: React.ReactNode; onPress?: () => void; isDisabled?: boolean}) { + return ( + + {children} + + ); + } + MockButton.Text = ({children}: {children: React.ReactNode}) => {children}; + return MockButton; +}); +jest.mock('@components/FixedFooter', () => ({children}: {children: React.ReactNode}) => {children}); +jest.mock('@components/Pressable', () => ({ + PressableWithFeedback: ({children, onPress, accessibilityLabel}: {children: React.ReactNode; onPress?: () => void; accessibilityLabel?: string}) => ( + + {children} + + ), +})); +jest.mock('@components/Text', () => ({children}: {children: React.ReactNode}) => {children}); + +const mockedUseSuggestedAgentRules = jest.mocked(useSuggestedAgentRules); +const mockedNavigate = jest.mocked(Navigation.navigate); + +const SUGGESTIONS: SuggestedAgentRule[] = [ + { + id: 'approveUnder75', + title: 'Approve any report that consists of expenses under $75', + prompt: 'Approve any report that consists of expenses under $75', + }, + { + id: 'blockGambling', + title: 'Block all spend from gambling or shady websites', + prompt: 'Block all spend from gambling or shady websites', + }, +]; + +describe('AddAgentRuleSuggestionsTab', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedUseSuggestedAgentRules.mockReturnValue({data: SUGGESTIONS, isLoading: false}); + }); + + it('renders suggestion titles from hook data', () => { + render(); + + expect(screen.getByText(SUGGESTIONS.at(0)?.title ?? '')).toBeOnTheScreen(); + expect(screen.getByText(SUGGESTIONS.at(1)?.title ?? '')).toBeOnTheScreen(); + }); + + it('calls onSelectSuggestion only after a card is chosen and Next is pressed', () => { + const onSelectSuggestion = jest.fn(); + render(); + + fireEvent.press(screen.getByText('common.next')); + expect(onSelectSuggestion).not.toHaveBeenCalled(); + + fireEvent.press(screen.getByLabelText(SUGGESTIONS.at(0)?.title ?? '')); + fireEvent.press(screen.getByText('common.next')); + + expect(onSelectSuggestion).toHaveBeenCalledTimes(1); + expect(onSelectSuggestion).toHaveBeenCalledWith(SUGGESTIONS.at(0)); + }); + + it('navigates to Concierge when help is pressed', () => { + render(); + + fireEvent.press(screen.getByText('workspace.rules.agentRules.getHelpFromConcierge')); + + expect(mockedNavigate).toHaveBeenCalledWith(ROUTES.CONCIERGE); + }); + + it('filters the list by search text', () => { + render(); + + fireEvent.changeText(screen.getByLabelText('workspace.rules.agentRules.findSuggestion'), 'gambling'); + + expect(screen.queryByText(SUGGESTIONS.at(0)?.title ?? '')).toBeNull(); + expect(screen.getByText(SUGGESTIONS.at(1)?.title ?? '')).toBeOnTheScreen(); + }); + + it('shows the empty state when there are no suggestions', () => { + mockedUseSuggestedAgentRules.mockReturnValue({data: [], isLoading: false}); + render(); + + expect(screen.getByText('workspace.rules.agentRules.emptySuggestionsTitle')).toBeOnTheScreen(); + expect(screen.getByText('workspace.rules.agentRules.emptySuggestionsSubtitle')).toBeOnTheScreen(); + }); +}); diff --git a/tests/unit/getSuggestedAgentRuleIconTest.ts b/tests/unit/getSuggestedAgentRuleIconTest.ts new file mode 100644 index 000000000000..f401da52e2a7 --- /dev/null +++ b/tests/unit/getSuggestedAgentRuleIconTest.ts @@ -0,0 +1,53 @@ +import {getSuggestedAgentRuleIcon} from '@libs/PolicyRulesUtils'; + +describe('getSuggestedAgentRuleIcon', () => { + it('maps approve rules to ThumbsUp', () => { + expect( + getSuggestedAgentRuleIcon({ + id: '1', + title: 'Approve any report that consists of expenses under $75', + prompt: 'Approve any report that consists of expenses under $75', + }), + ).toBe('ThumbsUp'); + }); + + it('maps block rules to CircleSlash', () => { + expect( + getSuggestedAgentRuleIcon({ + id: '2', + title: 'Block all spend from gambling or shady websites', + prompt: 'Block all spend from gambling or shady websites', + }), + ).toBe('CircleSlash'); + }); + + it('maps flag rules to Flag', () => { + expect( + getSuggestedAgentRuleIcon({ + id: '3', + title: 'Flag spend when someone spends above the category average', + prompt: 'Flag spend when someone spends above the category average', + }), + ).toBe('Flag'); + }); + + it('maps hotel spend limits to Coins', () => { + expect( + getSuggestedAgentRuleIcon({ + id: '4', + title: 'Set spend limit of $200/night for all hotels booked in New York City', + prompt: 'Set spend limit of $200/night for all hotels booked in New York City', + }), + ).toBe('Coins'); + }); + + it('defaults to ThumbsUp when no keyword matches', () => { + expect( + getSuggestedAgentRuleIcon({ + id: '5', + title: 'Something custom', + prompt: 'Do a custom thing', + }), + ).toBe('ThumbsUp'); + }); +}); From a2336485bfd1269c7709638da0f72cb2f9b01c35 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Sat, 11 Jul 2026 05:33:28 +0500 Subject: [PATCH 06/26] Update API command GET_AGENT_RULE_SUGGESTIONS and corresponding parameters. --- src/ONYXKEYS.ts | 4 +- src/hooks/useSuggestedAgentRules.ts | 2 +- .../GetAgentRuleSuggestionsParams.ts | 5 +++ src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 2 + src/libs/PolicyRulesUtils.ts | 11 +++-- src/libs/actions/Policy/Rules.ts | 17 +++++++- .../rules/AgentRules/AddAgentRulePage.tsx | 5 ++- tests/unit/getAgentRuleSuggestionsTest.ts | 25 +++++++++++ tests/unit/getSuggestedAgentRuleIconTest.ts | 42 +++++++++---------- 10 files changed, 81 insertions(+), 33 deletions(-) create mode 100644 src/libs/API/parameters/GetAgentRuleSuggestionsParams.ts create mode 100644 tests/unit/getAgentRuleSuggestionsTest.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 915c2a22bccf..66a272359845 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -158,7 +158,7 @@ const ONYXKEYS = { BETA_CONFIGURATION: 'betaConfiguration', /** Agent rule suggestions for the add-rule Suggestions tab */ - SUGGESTED_AGENT_RULES: 'suggestedAgentRules', + AGENT_RULE_SUGGESTIONS: 'agentRuleSuggestions', /** Whether the user is a member of a policy other than their personal */ HAS_NON_PERSONAL_POLICY: 'hasNonPersonalPolicy', @@ -1505,7 +1505,7 @@ type OnyxValuesMapping = { [ONYXKEYS.USER_METADATA]: OnyxTypes.UserMetadata; [ONYXKEYS.STASHED_SESSION]: OnyxTypes.Session; [ONYXKEYS.BETAS]: OnyxTypes.Beta[]; - [ONYXKEYS.SUGGESTED_AGENT_RULES]: OnyxTypes.SuggestedAgentRule[]; + [ONYXKEYS.AGENT_RULE_SUGGESTIONS]: OnyxTypes.SuggestedAgentRule[]; [ONYXKEYS.BETA_CONFIGURATION]: OnyxTypes.BetaConfiguration; [ONYXKEYS.NVP_MUTED_PLATFORMS]: Partial>; [ONYXKEYS.NVP_PRIORITY_MODE]: ValueOf; diff --git a/src/hooks/useSuggestedAgentRules.ts b/src/hooks/useSuggestedAgentRules.ts index c92aab6fc0af..e11667db62f2 100644 --- a/src/hooks/useSuggestedAgentRules.ts +++ b/src/hooks/useSuggestedAgentRules.ts @@ -16,7 +16,7 @@ type UseSuggestedAgentRulesResult = { }; function useSuggestedAgentRules(): UseSuggestedAgentRulesResult { - const [suggestions, metadata] = useOnyx(ONYXKEYS.SUGGESTED_AGENT_RULES); + const [suggestions, metadata] = useOnyx(ONYXKEYS.AGENT_RULE_SUGGESTIONS); const isLoading = isLoadingOnyxValue(metadata); return { diff --git a/src/libs/API/parameters/GetAgentRuleSuggestionsParams.ts b/src/libs/API/parameters/GetAgentRuleSuggestionsParams.ts new file mode 100644 index 000000000000..042aa6accb41 --- /dev/null +++ b/src/libs/API/parameters/GetAgentRuleSuggestionsParams.ts @@ -0,0 +1,5 @@ +type GetAgentRuleSuggestionsParams = { + policyID: string; +}; + +export default GetAgentRuleSuggestionsParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index c3ffceae66d0..5a8c097c70b7 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -70,6 +70,7 @@ export type {default as GetMissingOnyxMessagesParams} from './GetMissingOnyxMess export type {default as GetNewerActionsParams} from './GetNewerActionsParams'; export type {default as GetOlderActionsParams} from './GetOlderActionsParams'; export type {default as GetPolicyCategoriesParams} from './GetPolicyCategories'; +export type {default as GetAgentRuleSuggestionsParams} from './GetAgentRuleSuggestionsParams'; export type {default as GetReportPrivateNoteParams} from './GetReportPrivateNoteParams'; export type {default as GetRouteParams} from './GetRouteParams'; export type {default as CreateAppReportParams} from './CreateAppReportParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index df197aa4c5f0..c3a6a19f1859 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -1425,6 +1425,7 @@ const READ_COMMANDS = { GET_SAML_SETTINGS: 'GetSAMLSettings', GET_DUPLICATE_TRANSACTION_DETAILS: 'GetDuplicateTransactionDetails', GET_TRANSACTIONS_MATCHING_CODING_RULE: 'GetTransactionsMatchingCodingRule', + GET_AGENT_RULE_SUGGESTIONS: 'GetAgentRuleSuggestions', GET_ASSIGNED_SUPPORT_DATA: 'GetAssignedSupportData', OPEN_AGENTS_PAGE: 'OpenAgentsPage', OPEN_PROFILE_PAGE: 'OpenProfilePage', @@ -1530,6 +1531,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_DOMAIN_PAGE]: Parameters.OpenDomainPageParams; [READ_COMMANDS.GET_DUPLICATE_TRANSACTION_DETAILS]: Parameters.GetDuplicateTransactionDetailsParams; [READ_COMMANDS.GET_TRANSACTIONS_MATCHING_CODING_RULE]: Parameters.GetTransactionsMatchingCodingRuleParams; + [READ_COMMANDS.GET_AGENT_RULE_SUGGESTIONS]: Parameters.GetAgentRuleSuggestionsParams; [READ_COMMANDS.GET_ASSIGNED_SUPPORT_DATA]: null; [READ_COMMANDS.OPEN_AGENTS_PAGE]: null; [READ_COMMANDS.OPEN_PROFILE_PAGE]: null; diff --git a/src/libs/PolicyRulesUtils.ts b/src/libs/PolicyRulesUtils.ts index edfdcd89f55e..6368ae75c05e 100644 --- a/src/libs/PolicyRulesUtils.ts +++ b/src/libs/PolicyRulesUtils.ts @@ -5,13 +5,12 @@ import type SuggestedAgentRule from '@src/types/onyx/SuggestedAgentRule'; type SuggestedAgentRuleIcon = 'ThumbsUp' | 'CircleSlash' | 'Flag' | 'Coins'; /** - * Backend suggestions are {id, title, prompt} only, so Suggestions-tab icons are derived from copy. - * Unmatched suggestions fall back to ThumbsUp. + * Backend suggestions are {id, title, prompt} only. Icons are derived from id + title. */ const SUGGESTED_AGENT_RULE_ICON_RULES = [ - {icon: 'CircleSlash', keywords: ['block', 'prohibit', 'reject']}, - {icon: 'Flag', keywords: ['flag']}, - {icon: 'Coins', keywords: ['limit', '/night', 'hotel']}, + {icon: 'CircleSlash', keywords: ['block', 'banned', 'blocked', 'alcohol', 'gift-card', 'personal', 'ai-generated', 'handwritten', 'incorrect-receipt']}, + {icon: 'Flag', keywords: ['flag', 'mismatch', 'empty', 'mixed', 'age', 'weekend', 'suspected', 'unusually', 'within', 'window', 'deadline']}, + {icon: 'Coins', keywords: ['amount', 'total', 'cap', 'tip', 'currency', 'limit']}, ] as const satisfies ReadonlyArray<{icon: SuggestedAgentRuleIcon; keywords: readonly string[]}>; function isPendingDeleteOrUpdate(pendingAction: PendingAction | undefined): boolean { @@ -23,7 +22,7 @@ function getMccGroupDisplayName(groupID: string): string { } function getSuggestedAgentRuleIcon(suggestion: SuggestedAgentRule): SuggestedAgentRuleIcon { - const text = `${suggestion.title} ${suggestion.prompt}`.toLowerCase(); + const text = `${suggestion.id} ${suggestion.title}`.toLowerCase(); for (const rule of SUGGESTED_AGENT_RULE_ICON_RULES) { if (rule.keywords.some((keyword) => text.includes(keyword))) { diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 499592f7d956..2cc7a352fce9 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -1,5 +1,5 @@ import * as API from '@libs/API'; -import type {AddPolicyAgentRuleParams, DeletePolicyAgentRuleParams, UpdatePolicyAgentRuleParams} from '@libs/API/parameters'; +import type {AddPolicyAgentRuleParams, DeletePolicyAgentRuleParams, GetAgentRuleSuggestionsParams, UpdatePolicyAgentRuleParams} from '@libs/API/parameters'; import type OpenPolicyRulesPageParams from '@libs/API/parameters/OpenPolicyRulesPageParams'; import type SetPolicyCodingRuleParams from '@libs/API/parameters/SetPolicyCodingRuleParams'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; @@ -117,6 +117,20 @@ function openPolicyRulesPage(policyID: string | undefined) { API.read(READ_COMMANDS.OPEN_POLICY_RULES_PAGE, params); } +/** + * Fetches ready-made agent rule suggestions for the add-agent-rule Suggestions tab. + * Backend writes the list into ONYXKEYS.AGENT_RULE_SUGGESTIONS. + */ +function getAgentRuleSuggestions(policyID: string | undefined) { + if (!policyID) { + Log.warn('Invalid params for getAgentRuleSuggestions', {policyID}); + return; + } + + const params: GetAgentRuleSuggestionsParams = {policyID}; + API.read(READ_COMMANDS.GET_AGENT_RULE_SUGGESTIONS, params); +} + /** * Creates or updates a coding rule for the given policy * @param policyID - The ID of the policy to create/update the rule for @@ -593,6 +607,7 @@ function clearPolicyAgentRuleErrors(policyID: string, agentRuleID: string, agent export { openPolicyRulesPage, + getAgentRuleSuggestions, setPolicyCodingRule, deletePolicyCodingRule, getTransactionsMatchingCodingRule, diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx index b069b56864e2..a95eb9586b0d 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx @@ -26,7 +26,7 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import variables from '@styles/variables'; -import {addPolicyAgentRule} from '@userActions/Policy/Rules'; +import {addPolicyAgentRule, getAgentRuleSuggestions} from '@userActions/Policy/Rules'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -73,8 +73,9 @@ function AddAgentRulePage({ useEffect(() => { Tab.setSelectedTab(CONST.TAB.AGENT_RULE_TAB_TYPE, CONST.TAB.AGENT_RULE.SUGGESTIONS); + getAgentRuleSuggestions(policyID); return () => clearDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM); - }, []); + }, [policyID]); const tabs: TabSelectorBaseItem[] = [ { diff --git a/tests/unit/getAgentRuleSuggestionsTest.ts b/tests/unit/getAgentRuleSuggestionsTest.ts new file mode 100644 index 000000000000..e05937b2db36 --- /dev/null +++ b/tests/unit/getAgentRuleSuggestionsTest.ts @@ -0,0 +1,25 @@ +import {getAgentRuleSuggestions} from '@libs/actions/Policy/Rules'; +import * as API from '@libs/API'; +import {READ_COMMANDS} from '@libs/API/types'; + +jest.mock('@libs/API'); + +const mockRead = jest.mocked(API.read); + +describe('getAgentRuleSuggestions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls GetAgentRuleSuggestions with the policyID', () => { + getAgentRuleSuggestions('policy123'); + + expect(mockRead).toHaveBeenCalledWith(READ_COMMANDS.GET_AGENT_RULE_SUGGESTIONS, {policyID: 'policy123'}); + }); + + it('does not call the API when policyID is missing', () => { + getAgentRuleSuggestions(undefined); + + expect(mockRead).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/getSuggestedAgentRuleIconTest.ts b/tests/unit/getSuggestedAgentRuleIconTest.ts index f401da52e2a7..dda060b5b2ee 100644 --- a/tests/unit/getSuggestedAgentRuleIconTest.ts +++ b/tests/unit/getSuggestedAgentRuleIconTest.ts @@ -1,50 +1,50 @@ import {getSuggestedAgentRuleIcon} from '@libs/PolicyRulesUtils'; describe('getSuggestedAgentRuleIcon', () => { - it('maps approve rules to ThumbsUp', () => { + it('maps amount/cap titles to Coins even when the prompt says Reject', () => { expect( getSuggestedAgentRuleIcon({ - id: '1', - title: 'Approve any report that consists of expenses under $75', - prompt: 'Approve any report that consists of expenses under $75', + id: 'expense-amount-over', + title: 'Per-expense amount over a cap', + prompt: 'Reject any single expense of $75 or more.', }), - ).toBe('ThumbsUp'); + ).toBe('Coins'); }); - it('maps block rules to CircleSlash', () => { + it('maps flagged titles to Flag', () => { expect( getSuggestedAgentRuleIcon({ - id: '2', - title: 'Block all spend from gambling or shady websites', - prompt: 'Block all spend from gambling or shady websites', + id: 'expense-amount-between', + title: 'Per-expense amount within a flagged band', + prompt: 'Reject expenses with an amount between $500 and $1000 inclusive.', }), - ).toBe('CircleSlash'); + ).toBe('Flag'); }); - it('maps flag rules to Flag', () => { + it('maps banned/block titles to CircleSlash', () => { expect( getSuggestedAgentRuleIcon({ - id: '3', - title: 'Flag spend when someone spends above the category average', - prompt: 'Flag spend when someone spends above the category average', + id: 'merchant-contains', + title: 'Merchant name contains a banned word', + prompt: 'Reject any expense whose merchant name contains the word "casino".', }), - ).toBe('Flag'); + ).toBe('CircleSlash'); }); - it('maps hotel spend limits to Coins', () => { + it('defaults allow-list / category rules to ThumbsUp', () => { expect( getSuggestedAgentRuleIcon({ - id: '4', - title: 'Set spend limit of $200/night for all hotels booked in New York City', - prompt: 'Set spend limit of $200/night for all hotels booked in New York City', + id: 'category-is', + title: 'Category is a specific value', + prompt: 'Only approve expenses categorized as "Travel". Reject expenses in any other category.', }), - ).toBe('Coins'); + ).toBe('ThumbsUp'); }); it('defaults to ThumbsUp when no keyword matches', () => { expect( getSuggestedAgentRuleIcon({ - id: '5', + id: 'custom', title: 'Something custom', prompt: 'Do a custom thing', }), From c3e2bdac6abae22646f5bac7fc5607da991963a6 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Sat, 11 Jul 2026 06:08:37 +0500 Subject: [PATCH 07/26] impliment review feedback --- .../AgentRules/AddAgentRuleSuggestionsTab.tsx | 18 ++++++++---------- .../rules/AgentRules/AddAgentRuleWriteTab.tsx | 1 + 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab.tsx index 362d7d2fb842..e9d2b47cf62b 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab.tsx @@ -28,10 +28,11 @@ import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type SuggestedAgentRule from '@src/types/onyx/SuggestedAgentRule'; -import React, {useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; type AddAgentRuleSuggestionsTabProps = { + /** Called with the chosen suggestion when the user presses Next */ onSelectSuggestion: (suggestion: SuggestedAgentRule) => void; }; @@ -48,15 +49,12 @@ function AddAgentRuleSuggestionsTab({onSelectSuggestion}: AddAgentRuleSuggestion const [searchValue, setSearchValue] = useState(''); const [selectedSuggestionID, setSelectedSuggestionID] = useState(); - const filteredSuggestions = useMemo(() => { - const trimmedSearch = searchValue.trim().toLowerCase(); - if (!trimmedSearch) { - return data; - } - return data.filter((suggestion) => suggestion.title.toLowerCase().includes(trimmedSearch) || suggestion.prompt.toLowerCase().includes(trimmedSearch)); - }, [data, searchValue]); + const trimmedSearch = searchValue.trim().toLowerCase(); + const filteredSuggestions = !trimmedSearch + ? data + : data.filter((suggestion) => suggestion.title.toLowerCase().includes(trimmedSearch) || suggestion.prompt.toLowerCase().includes(trimmedSearch)); - const selectedSuggestion = data.find((suggestion) => suggestion.id === selectedSuggestionID); + const selectedSuggestion = filteredSuggestions.find((suggestion) => suggestion.id === selectedSuggestionID); const goToConcierge = () => { Navigation.navigate(ROUTES.CONCIERGE); @@ -123,7 +121,7 @@ function AddAgentRuleSuggestionsTab({onSelectSuggestion}: AddAgentRuleSuggestion setSelectedSuggestionID(suggestion.id)} wrapperStyle={[styles.mh5]} style={[ diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx index 26003d51650e..a7e2732eb0eb 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx @@ -23,6 +23,7 @@ import {View} from 'react-native'; type AddAgentRuleFormID = typeof ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM; type AddAgentRuleWriteTabProps = { + /** Called with the form values when the user saves the rule */ onSave: (values: FormOnyxValues) => void; }; From cb4cd9b0bbc8759a8a17e5d7cbaea571faffd5a4 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Sat, 11 Jul 2026 06:08:50 +0500 Subject: [PATCH 08/26] Add test to disable Next button when selected suggestion is filtered out --- tests/ui/AddAgentRuleSuggestionsTabTest.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/ui/AddAgentRuleSuggestionsTabTest.tsx b/tests/ui/AddAgentRuleSuggestionsTabTest.tsx index 14bbf0d74a31..85083b8f0f31 100644 --- a/tests/ui/AddAgentRuleSuggestionsTabTest.tsx +++ b/tests/ui/AddAgentRuleSuggestionsTabTest.tsx @@ -150,6 +150,17 @@ describe('AddAgentRuleSuggestionsTab', () => { expect(screen.getByText(SUGGESTIONS.at(1)?.title ?? '')).toBeOnTheScreen(); }); + it('disables Next when the selected suggestion is filtered out', () => { + const onSelectSuggestion = jest.fn(); + render(); + + fireEvent.press(screen.getByLabelText(SUGGESTIONS.at(0)?.title ?? '')); + fireEvent.changeText(screen.getByLabelText('workspace.rules.agentRules.findSuggestion'), 'gambling'); + fireEvent.press(screen.getByText('common.next')); + + expect(onSelectSuggestion).not.toHaveBeenCalled(); + }); + it('shows the empty state when there are no suggestions', () => { mockedUseSuggestedAgentRules.mockReturnValue({data: [], isLoading: false}); render(); From 3f6f74d9580cfc3c00d43ff87419ba8f84dcb452 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Sat, 11 Jul 2026 06:47:32 +0500 Subject: [PATCH 09/26] Add loading state management for agent rule suggestions --- src/ONYXKEYS.ts | 4 ++++ src/hooks/useSuggestedAgentRules.ts | 6 ++--- src/libs/PolicyRulesUtils.ts | 1 - src/libs/actions/Policy/Rules.ts | 25 +++++++++++++++++++-- tests/ui/AddAgentRuleSuggestionsTabTest.tsx | 7 ++++++ tests/unit/getAgentRuleSuggestionsTest.ts | 25 --------------------- 6 files changed, 37 insertions(+), 31 deletions(-) delete mode 100644 tests/unit/getAgentRuleSuggestionsTest.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 66a272359845..2bf66def5abe 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -433,6 +433,9 @@ const ONYXKEYS = { /** Is loading policy rules preview? */ IS_LOADING_POLICY_CODING_RULES_PREVIEW: 'isLoadingPolicyCodingRulesPreview', + /** Is loading agent rule suggestions for the add-rule Suggestions tab? */ + IS_LOADING_AGENT_RULE_SUGGESTIONS: 'isLoadingAgentRuleSuggestions', + /** Set when we are loading fresh subscription/billing data from the server */ IS_LOADING_SUBSCRIPTION_DATA: 'isLoadingSubscriptionData', @@ -1575,6 +1578,7 @@ type OnyxValuesMapping = { [ONYXKEYS.IS_LOADING_SHARE_BANK_ACCOUNTS]: boolean; [ONYXKEYS.IS_LOADING_BULK_CHANGE_APPROVER_PAGE]: boolean; [ONYXKEYS.IS_LOADING_POLICY_CODING_RULES_PREVIEW]: boolean; + [ONYXKEYS.IS_LOADING_AGENT_RULE_SUGGESTIONS]: boolean; [ONYXKEYS.IS_LOADING_REPORT_DATA]: boolean; [ONYXKEYS.IS_SEARCH_FILTERS_CARD_DATA_LOADED]: boolean; [ONYXKEYS.IS_LOADING_SUBSCRIPTION_DATA]: boolean; diff --git a/src/hooks/useSuggestedAgentRules.ts b/src/hooks/useSuggestedAgentRules.ts index e11667db62f2..773a74d60eb8 100644 --- a/src/hooks/useSuggestedAgentRules.ts +++ b/src/hooks/useSuggestedAgentRules.ts @@ -11,13 +11,14 @@ type UseSuggestedAgentRulesResult = { /** Suggested rules from Onyx, or an empty array while loading or when no suggestion exist */ data: SuggestedAgentRule[]; - /** Whether the Onyx key is loading */ + /** Whether Onyx is hydrating or the suggestions request is fetching data */ isLoading: boolean; }; function useSuggestedAgentRules(): UseSuggestedAgentRulesResult { const [suggestions, metadata] = useOnyx(ONYXKEYS.AGENT_RULE_SUGGESTIONS); - const isLoading = isLoadingOnyxValue(metadata); + const [isFetchingSuggestions] = useOnyx(ONYXKEYS.IS_LOADING_AGENT_RULE_SUGGESTIONS); + const isLoading = isLoadingOnyxValue(metadata) || !!isFetchingSuggestions; return { data: suggestions ?? [], @@ -26,4 +27,3 @@ function useSuggestedAgentRules(): UseSuggestedAgentRulesResult { } export default useSuggestedAgentRules; -export type {UseSuggestedAgentRulesResult}; diff --git a/src/libs/PolicyRulesUtils.ts b/src/libs/PolicyRulesUtils.ts index 6368ae75c05e..ae20dd40b3ba 100644 --- a/src/libs/PolicyRulesUtils.ts +++ b/src/libs/PolicyRulesUtils.ts @@ -34,4 +34,3 @@ function getSuggestedAgentRuleIcon(suggestion: SuggestedAgentRule): SuggestedAge } export {getMccGroupDisplayName, getSuggestedAgentRuleIcon, isPendingDeleteOrUpdate}; -export type {SuggestedAgentRuleIcon}; diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 2cc7a352fce9..0974822885ea 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -119,7 +119,6 @@ function openPolicyRulesPage(policyID: string | undefined) { /** * Fetches ready-made agent rule suggestions for the add-agent-rule Suggestions tab. - * Backend writes the list into ONYXKEYS.AGENT_RULE_SUGGESTIONS. */ function getAgentRuleSuggestions(policyID: string | undefined) { if (!policyID) { @@ -128,7 +127,29 @@ function getAgentRuleSuggestions(policyID: string | undefined) { } const params: GetAgentRuleSuggestionsParams = {policyID}; - API.read(READ_COMMANDS.GET_AGENT_RULE_SUGGESTIONS, params); + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IS_LOADING_AGENT_RULE_SUGGESTIONS, + value: true, + }, + ]; + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IS_LOADING_AGENT_RULE_SUGGESTIONS, + value: false, + }, + ]; + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IS_LOADING_AGENT_RULE_SUGGESTIONS, + value: false, + }, + ]; + + API.read(READ_COMMANDS.GET_AGENT_RULE_SUGGESTIONS, params, {optimisticData, successData, failureData}); } /** diff --git a/tests/ui/AddAgentRuleSuggestionsTabTest.tsx b/tests/ui/AddAgentRuleSuggestionsTabTest.tsx index 85083b8f0f31..f0730e88a4fb 100644 --- a/tests/ui/AddAgentRuleSuggestionsTabTest.tsx +++ b/tests/ui/AddAgentRuleSuggestionsTabTest.tsx @@ -161,6 +161,13 @@ describe('AddAgentRuleSuggestionsTab', () => { expect(onSelectSuggestion).not.toHaveBeenCalled(); }); + it('shows a loading indicator while suggestions are loading', () => { + mockedUseSuggestedAgentRules.mockReturnValue({data: [], isLoading: true}); + render(); + + expect(screen.queryByText('workspace.rules.agentRules.emptySuggestionsTitle')).toBeNull(); + }); + it('shows the empty state when there are no suggestions', () => { mockedUseSuggestedAgentRules.mockReturnValue({data: [], isLoading: false}); render(); diff --git a/tests/unit/getAgentRuleSuggestionsTest.ts b/tests/unit/getAgentRuleSuggestionsTest.ts deleted file mode 100644 index e05937b2db36..000000000000 --- a/tests/unit/getAgentRuleSuggestionsTest.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {getAgentRuleSuggestions} from '@libs/actions/Policy/Rules'; -import * as API from '@libs/API'; -import {READ_COMMANDS} from '@libs/API/types'; - -jest.mock('@libs/API'); - -const mockRead = jest.mocked(API.read); - -describe('getAgentRuleSuggestions', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('calls GetAgentRuleSuggestions with the policyID', () => { - getAgentRuleSuggestions('policy123'); - - expect(mockRead).toHaveBeenCalledWith(READ_COMMANDS.GET_AGENT_RULE_SUGGESTIONS, {policyID: 'policy123'}); - }); - - it('does not call the API when policyID is missing', () => { - getAgentRuleSuggestions(undefined); - - expect(mockRead).not.toHaveBeenCalled(); - }); -}); From 04a7dead2c0742761e8aa24e731457fedb27a3cb Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Mon, 13 Jul 2026 07:48:00 +0500 Subject: [PATCH 10/26] fix typo in agent rule suggestion comment Co-authored-by: Manan --- src/hooks/useSuggestedAgentRules.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useSuggestedAgentRules.ts b/src/hooks/useSuggestedAgentRules.ts index 773a74d60eb8..7c874a09aaf3 100644 --- a/src/hooks/useSuggestedAgentRules.ts +++ b/src/hooks/useSuggestedAgentRules.ts @@ -8,7 +8,7 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import useOnyx from './useOnyx'; type UseSuggestedAgentRulesResult = { - /** Suggested rules from Onyx, or an empty array while loading or when no suggestion exist */ + /** Suggested rules from Onyx, or an empty array while loading or when no suggestions exist */ data: SuggestedAgentRule[]; /** Whether Onyx is hydrating or the suggestions request is fetching data */ From d44547b7bc567754eadd8e91486de641487051c2 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Mon, 13 Jul 2026 08:02:15 +0500 Subject: [PATCH 11/26] update addRuleTitle key to newRuleTitle --- src/languages/de.ts | 2 +- src/languages/en.ts | 2 +- src/languages/es.ts | 2 +- src/languages/fr.ts | 2 +- src/languages/it.ts | 2 +- src/languages/ja.ts | 2 +- src/languages/nl.ts | 2 +- src/languages/pl.ts | 2 +- src/languages/pt-BR.ts | 2 +- src/languages/zh-hans.ts | 2 +- src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 8282792ba7be..144a76eb9bb8 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7701,7 +7701,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc ruleBotName: 'RuleBot', addRule: 'Agentenregel hinzufügen', findRule: 'Agentenregel finden', - addRuleTitle: 'Neue Regel', + newRuleTitle: 'Neue Regel', editRuleTitle: 'Regel bearbeiten', deleteRule: 'Regel löschen', deleteRuleConfirmation: 'Sind Sie sicher, dass Sie diese Regel löschen möchten?', diff --git a/src/languages/en.ts b/src/languages/en.ts index 7e74440857a3..93e9d08a2390 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7995,7 +7995,7 @@ const translations = { ruleBotName: 'RuleBot', addRule: 'Add agent rule', findRule: 'Find agent rule', - addRuleTitle: 'New rule', + newRuleTitle: 'New rule', editRuleTitle: 'Edit rule', deleteRule: 'Delete rule', deleteRuleConfirmation: 'Are you sure you want to delete this rule?', diff --git a/src/languages/es.ts b/src/languages/es.ts index 8234a103e06c..6cc700695ad9 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7592,7 +7592,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, ruleBotName: 'RuleBot', addRule: 'Añadir regla de agente', findRule: 'Encontrar regla de agente', - addRuleTitle: 'Nueva regla', + newRuleTitle: 'Nueva regla', describeRuleTitle: 'Describe la regla que debe seguir tu agente de IA', describeRuleHeadline: 'Describe tu regla', describeRuleSubtitle: 'Describe tu regla y Concierge la creará', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 5e313b46ce01..4c3e4c4bb4fa 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7730,7 +7730,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e ruleBotName: 'RuleBot', addRule: 'Ajouter une règle d’agent', findRule: 'Rechercher une règle d’agent', - addRuleTitle: 'Nouvelle règle', + newRuleTitle: 'Nouvelle règle', editRuleTitle: 'Modifier la règle', deleteRule: 'Supprimer la règle', deleteRuleConfirmation: 'Voulez-vous vraiment supprimer cette règle ?', diff --git a/src/languages/it.ts b/src/languages/it.ts index 54019ce1bb7d..2d06e31e5d65 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7684,7 +7684,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, ruleBotName: 'RuleBot', addRule: 'Aggiungi regola agente', findRule: 'Trova regola agente', - addRuleTitle: 'Nuova regola', + newRuleTitle: 'Nuova regola', editRuleTitle: 'Modifica regola', deleteRule: 'Elimina regola', deleteRuleConfirmation: 'Sei sicuro di voler eliminare questa regola?', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index d1eca107a495..350d114a05b1 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7587,7 +7587,7 @@ ${reportName}`, ruleBotName: 'RuleBot', addRule: 'エージェントルールを追加', findRule: 'エージェントルールを検索', - addRuleTitle: '新しいルール', + newRuleTitle: '新しいルール', editRuleTitle: 'ルールを編集', deleteRule: 'ルールを削除', deleteRuleConfirmation: 'このルールを削除してもよろしいですか?', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 30964f8b43e1..8b1df8860236 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7662,7 +7662,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, ruleBotName: 'RuleBot', addRule: 'Agentregel toevoegen', findRule: 'Agentregel zoeken', - addRuleTitle: 'Nieuwe regel', + newRuleTitle: 'Nieuwe regel', editRuleTitle: 'Regel bewerken', deleteRule: 'Regel verwijderen', deleteRuleConfirmation: 'Weet je zeker dat je deze regel wilt verwijderen?', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 719ef2794c94..c7f0e71dddfd 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7641,7 +7641,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, ruleBotName: 'RuleBot', addRule: 'Dodaj regułę agenta', findRule: 'Znajdź regułę agenta', - addRuleTitle: 'Nowa reguła', + newRuleTitle: 'Nowa reguła', editRuleTitle: 'Edytuj regułę', deleteRule: 'Usuń regułę', deleteRuleConfirmation: 'Na pewno chcesz usunąć tę regułę?', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 60f1896cd3b2..8a83bc70df36 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7654,7 +7654,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, ruleBotName: 'RuleBot', addRule: 'Adicionar regra de agente', findRule: 'Encontrar regra de agente', - addRuleTitle: 'Nova regra', + newRuleTitle: 'Nova regra', editRuleTitle: 'Editar regra', deleteRule: 'Excluir regra', deleteRuleConfirmation: 'Tem certeza de que quer excluir esta regra?', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 5b572b81f55d..4d1d83d38617 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7436,7 +7436,7 @@ ${reportName}`, ruleBotName: 'RuleBot', addRule: '添加代理规则', findRule: '查找代理规则', - addRuleTitle: '新规则', + newRuleTitle: '新规则', editRuleTitle: '编辑规则', deleteRule: '删除规则', deleteRuleConfirmation: '确定要删除此规则吗?', diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx index a95eb9586b0d..0763ca1648dd 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx @@ -155,7 +155,7 @@ function AddAgentRulePage({ includeSafeAreaPaddingBottom shouldEnableMaxHeight > - + Date: Mon, 13 Jul 2026 09:18:28 +0500 Subject: [PATCH 12/26] check isOffline before calling getAgentRuleSuggestions --- src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx index 0763ca1648dd..14dfe2557fc3 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx @@ -10,6 +10,7 @@ import type {TabSelectorBaseItem} from '@components/TabSelector/types'; import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; @@ -62,6 +63,7 @@ function AddAgentRulePage({ }: AddAgentRulePageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + const {isOffline} = useNetwork(); const {isBetaEnabled} = usePermissions(); const isCustomAgentEnabled = isBetaEnabled(CONST.BETAS.CUSTOM_AGENT); const policy = usePolicy(policyID); @@ -73,9 +75,11 @@ function AddAgentRulePage({ useEffect(() => { Tab.setSelectedTab(CONST.TAB.AGENT_RULE_TAB_TYPE, CONST.TAB.AGENT_RULE.SUGGESTIONS); - getAgentRuleSuggestions(policyID); + if (!isOffline) { + getAgentRuleSuggestions(policyID); + } return () => clearDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM); - }, [policyID]); + }, [policyID, isOffline]); const tabs: TabSelectorBaseItem[] = [ { From f3176788420fc5315e15e1c06bc44ff721afe0d0 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Mon, 13 Jul 2026 10:28:06 +0500 Subject: [PATCH 13/26] Refactor AddAgentRulePage to manage active tab state with useState and reset to Suggestions on workspace change --- .../rules/AgentRules/AddAgentRulePage.tsx | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx index 14dfe2557fc3..a4f462ee9826 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx @@ -11,7 +11,6 @@ import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; -import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -39,7 +38,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {ValueOf} from 'type-fest'; -import React, {useEffect, useRef} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {AddAgentRuleFormID} from './AddAgentRuleWriteTab'; @@ -69,10 +68,16 @@ function AddAgentRulePage({ const policy = usePolicy(policyID); const linkPressedRef = useRef(false); const {showConfirmModal, closeModal} = useConfirmModal(); - const [lastSelectedTab] = useOnyx(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.AGENT_RULE_TAB_TYPE}`); - const activeTab: AgentRuleTab = lastSelectedTab && isAgentRuleTab(lastSelectedTab) ? lastSelectedTab : CONST.TAB.AGENT_RULE.SUGGESTIONS; + const [activeTab, setActiveTab] = useState(CONST.TAB.AGENT_RULE.SUGGESTIONS); + const [activeTabPolicyID, setActiveTabPolicyID] = useState(policyID); const tabIcons = useMemoizedLazyExpensifyIcons(['Feed', 'Pencil']); + // Reset the active tab to Suggestions when the workspace changes. + if (activeTabPolicyID !== policyID) { + setActiveTabPolicyID(policyID); + setActiveTab(CONST.TAB.AGENT_RULE.SUGGESTIONS); + } + useEffect(() => { Tab.setSelectedTab(CONST.TAB.AGENT_RULE_TAB_TYPE, CONST.TAB.AGENT_RULE.SUGGESTIONS); if (!isOffline) { @@ -95,10 +100,22 @@ function AddAgentRulePage({ ]; const selectSuggestion = (suggestion: SuggestedAgentRule) => { + if (!suggestion.prompt?.trim()) { + return; + } setDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM, {[INPUT_IDS.PROMPT]: suggestion.prompt}); + setActiveTab(CONST.TAB.AGENT_RULE.WRITE); Tab.setSelectedTab(CONST.TAB.AGENT_RULE_TAB_TYPE, CONST.TAB.AGENT_RULE.WRITE); }; + const selectTab = (key: string) => { + if (!isAgentRuleTab(key)) { + return; + } + setActiveTab(key); + Tab.setSelectedTab(CONST.TAB.AGENT_RULE_TAB_TYPE, key); + }; + const saveRule = (values: FormOnyxValues): void => { // When the workspace has no agent rules yet, the backend creates the "RuleBot" agent and adds it as // an admin. Surface a one-time modal explaining this side effect before navigating back. @@ -165,12 +182,7 @@ function AddAgentRulePage({ { - if (!isAgentRuleTab(key)) { - return; - } - Tab.setSelectedTab(CONST.TAB.AGENT_RULE_TAB_TYPE, key); - }} + onTabPress={selectTab} equalWidth /> From 8d0a0062580ff36f52664ac530ee68557df49dda Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Mon, 13 Jul 2026 10:31:33 +0500 Subject: [PATCH 14/26] update suggested agent rule icon logic with priority-based matching and update related components. --- src/libs/PolicyRulesUtils.ts | 60 ++++++++++++++++--- .../AgentRules/AddAgentRuleSuggestionsTab.tsx | 13 ++-- tests/unit/getSuggestedAgentRuleIconTest.ts | 60 ++++++++++++++++++- 3 files changed, 118 insertions(+), 15 deletions(-) diff --git a/src/libs/PolicyRulesUtils.ts b/src/libs/PolicyRulesUtils.ts index ae20dd40b3ba..6cfd7b59bdf6 100644 --- a/src/libs/PolicyRulesUtils.ts +++ b/src/libs/PolicyRulesUtils.ts @@ -2,16 +2,48 @@ import CONST from '@src/CONST'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import type SuggestedAgentRule from '@src/types/onyx/SuggestedAgentRule'; +import StringUtils from './StringUtils'; + type SuggestedAgentRuleIcon = 'ThumbsUp' | 'CircleSlash' | 'Flag' | 'Coins'; +type SuggestedAgentRuleIconRule = { + /** Icon to show when any keyword matches */ + icon: Exclude; + + /** Higher priority wins when multiple rules match the same suggestion. */ + priority: number; + + /** keywords matched against suggestion id + title */ + keywords: readonly string[]; +}; + +/** Fallback when no keyword rule matches */ +const DEFAULT_SUGGESTED_AGENT_RULE_ICON: SuggestedAgentRuleIcon = 'ThumbsUp'; + /** * Backend suggestions are {id, title, prompt} only. Icons are derived from id + title. + * Keywords are matched as whole tokens so substrings like "age"/"cap" do not match "manage"/"capital". */ + const SUGGESTED_AGENT_RULE_ICON_RULES = [ - {icon: 'CircleSlash', keywords: ['block', 'banned', 'blocked', 'alcohol', 'gift-card', 'personal', 'ai-generated', 'handwritten', 'incorrect-receipt']}, - {icon: 'Flag', keywords: ['flag', 'mismatch', 'empty', 'mixed', 'age', 'weekend', 'suspected', 'unusually', 'within', 'window', 'deadline']}, - {icon: 'Coins', keywords: ['amount', 'total', 'cap', 'tip', 'currency', 'limit']}, -] as const satisfies ReadonlyArray<{icon: SuggestedAgentRuleIcon; keywords: readonly string[]}>; + { + icon: 'Flag', + priority: 1, + keywords: ['flag', 'flagged', 'mismatch', 'empty', 'mixed', 'age', 'weekend', 'suspected', 'unusually', 'within', 'window', 'deadline'], + }, + { + icon: 'Coins', + priority: 2, + keywords: ['amount', 'total', 'cap', 'tip', 'currency', 'limit'], + }, + { + icon: 'CircleSlash', + priority: 3, + keywords: ['block', 'banned', 'blocked', 'alcohol', 'gift-card', 'personal', 'ai-generated', 'handwritten', 'incorrect-receipt'], + }, +] as const satisfies readonly SuggestedAgentRuleIconRule[]; + +const SUGGESTED_AGENT_RULE_ICON_NAMES = [DEFAULT_SUGGESTED_AGENT_RULE_ICON, ...SUGGESTED_AGENT_RULE_ICON_RULES.map((rule) => rule.icon)] as const; function isPendingDeleteOrUpdate(pendingAction: PendingAction | undefined): boolean { return pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE; @@ -21,16 +53,28 @@ function getMccGroupDisplayName(groupID: string): string { return groupID.charAt(0).toUpperCase() + groupID.slice(1); } +/** + * Returns true when keyword appears as a whole token in text. + */ +function textIncludesKeywordToken(text: string, keyword: string): boolean { + return new RegExp(`(?:^|[^a-z0-9])${StringUtils.escapeRegExp(keyword)}(?:$|[^a-z0-9])`).test(text); +} + function getSuggestedAgentRuleIcon(suggestion: SuggestedAgentRule): SuggestedAgentRuleIcon { const text = `${suggestion.id} ${suggestion.title}`.toLowerCase(); + let bestMatchingRule: SuggestedAgentRuleIconRule | undefined; for (const rule of SUGGESTED_AGENT_RULE_ICON_RULES) { - if (rule.keywords.some((keyword) => text.includes(keyword))) { - return rule.icon; + const hasKeywordMatch = rule.keywords.some((keyword) => textIncludesKeywordToken(text, keyword)); + if (!hasKeywordMatch) { + continue; + } + if (!bestMatchingRule || rule.priority > bestMatchingRule.priority) { + bestMatchingRule = rule; } } - return 'ThumbsUp'; + return bestMatchingRule?.icon ?? DEFAULT_SUGGESTED_AGENT_RULE_ICON; } -export {getMccGroupDisplayName, getSuggestedAgentRuleIcon, isPendingDeleteOrUpdate}; +export {getMccGroupDisplayName, getSuggestedAgentRuleIcon, isPendingDeleteOrUpdate, SUGGESTED_AGENT_RULE_ICON_NAMES}; diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab.tsx index e9d2b47cf62b..0686c695f716 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab.tsx @@ -20,7 +20,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; -import {getSuggestedAgentRuleIcon} from '@libs/PolicyRulesUtils'; +import {getSuggestedAgentRuleIcon, SUGGESTED_AGENT_RULE_ICON_NAMES} from '@libs/PolicyRulesUtils'; import variables from '@styles/variables'; @@ -36,8 +36,6 @@ type AddAgentRuleSuggestionsTabProps = { onSelectSuggestion: (suggestion: SuggestedAgentRule) => void; }; -const SUGGESTION_ICON_NAMES = ['ThumbsUp', 'CircleSlash', 'Flag', 'Coins'] as const; - function AddAgentRuleSuggestionsTab({onSelectSuggestion}: AddAgentRuleSuggestionsTabProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -45,7 +43,7 @@ function AddAgentRuleSuggestionsTab({onSelectSuggestion}: AddAgentRuleSuggestion const {isOffline} = useNetwork(); const {data, isLoading} = useSuggestedAgentRules(); const illustrations = useMemoizedLazyIllustrations(['Lightbulb']); - const icons = useMemoizedLazyExpensifyIcons([...SUGGESTION_ICON_NAMES]); + const icons = useMemoizedLazyExpensifyIcons([...SUGGESTED_AGENT_RULE_ICON_NAMES]); const [searchValue, setSearchValue] = useState(''); const [selectedSuggestionID, setSelectedSuggestionID] = useState(); @@ -55,6 +53,9 @@ function AddAgentRuleSuggestionsTab({onSelectSuggestion}: AddAgentRuleSuggestion : data.filter((suggestion) => suggestion.title.toLowerCase().includes(trimmedSearch) || suggestion.prompt.toLowerCase().includes(trimmedSearch)); const selectedSuggestion = filteredSuggestions.find((suggestion) => suggestion.id === selectedSuggestionID); + const hasNoSuggestions = data.length === 0; + const shouldShowLoadingIndicator = isLoading && hasNoSuggestions; + const shouldShowEmptyState = !isLoading && hasNoSuggestions; const goToConcierge = () => { Navigation.navigate(ROUTES.CONCIERGE); @@ -67,7 +68,7 @@ function AddAgentRuleSuggestionsTab({onSelectSuggestion}: AddAgentRuleSuggestion onSelectSuggestion(selectedSuggestion); }; - if (isLoading && data.length === 0) { + if (shouldShowLoadingIndicator) { return ( { ).toBe('Coins'); }); - it('maps flagged titles to Flag', () => { + it('prefers Coins over Flag when amount and within both match', () => { expect( getSuggestedAgentRuleIcon({ id: 'expense-amount-between', title: 'Per-expense amount within a flagged band', prompt: 'Reject expenses with an amount between $500 and $1000 inclusive.', }), + ).toBe('Coins'); + }); + + it('prefers CircleSlash over Coins when block and amount both match', () => { + expect( + getSuggestedAgentRuleIcon({ + id: 'blocked-amount', + title: 'Block expenses over an amount cap', + prompt: 'Reject blocked high-amount spend.', + }), + ).toBe('CircleSlash'); + }); + + it('maps flagged titles without money keywords to Flag', () => { + expect( + getSuggestedAgentRuleIcon({ + id: 'receipt-mismatch', + title: 'Receipt mismatch on weekend', + prompt: 'Flag expenses with a receipt mismatch.', + }), ).toBe('Flag'); }); @@ -50,4 +70,42 @@ describe('getSuggestedAgentRuleIcon', () => { }), ).toBe('ThumbsUp'); }); + + it('does not match age inside agent or manage', () => { + expect( + getSuggestedAgentRuleIcon({ + id: 'agent-rules', + title: 'Agent rules for workspace', + prompt: 'Describe agent rules.', + }), + ).toBe('ThumbsUp'); + + expect( + getSuggestedAgentRuleIcon({ + id: 'manage-approvals', + title: 'Manage approval workflow', + prompt: 'Manage approvals carefully.', + }), + ).toBe('ThumbsUp'); + }); + + it('does not match cap inside capital', () => { + expect( + getSuggestedAgentRuleIcon({ + id: 'capital-expense', + title: 'Capital expense approval', + prompt: 'Approve capital expenses.', + }), + ).toBe('ThumbsUp'); + }); + + it('does not match age inside page', () => { + expect( + getSuggestedAgentRuleIcon({ + id: 'page-count', + title: 'Page count limits', + prompt: 'Limit page count.', + }), + ).toBe('ThumbsUp'); + }); }); From 3f3d465163b98538f621fb71bd546df9f3d555c0 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Mon, 13 Jul 2026 10:50:48 +0500 Subject: [PATCH 15/26] add optional chaining in filter, change TextInput role to SEARCHBOX, add accessibilityState --- .../rules/AgentRules/AddAgentRuleSuggestionsTab.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab.tsx index 0686c695f716..aab072589b52 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab.tsx @@ -50,7 +50,7 @@ function AddAgentRuleSuggestionsTab({onSelectSuggestion}: AddAgentRuleSuggestion const trimmedSearch = searchValue.trim().toLowerCase(); const filteredSuggestions = !trimmedSearch ? data - : data.filter((suggestion) => suggestion.title.toLowerCase().includes(trimmedSearch) || suggestion.prompt.toLowerCase().includes(trimmedSearch)); + : data.filter((suggestion) => suggestion.title?.toLowerCase().includes(trimmedSearch) || suggestion.prompt?.toLowerCase().includes(trimmedSearch)); const selectedSuggestion = filteredSuggestions.find((suggestion) => suggestion.id === selectedSuggestionID); const hasNoSuggestions = data.length === 0; @@ -99,7 +99,7 @@ function AddAgentRuleSuggestionsTab({onSelectSuggestion}: AddAgentRuleSuggestion value={searchValue} onChangeText={setSearchValue} autoGrowHeight={false} - role={CONST.ROLE.PRESENTATION} + role={CONST.ROLE.SEARCHBOX} /> setSelectedSuggestionID(suggestion.id)} wrapperStyle={[styles.mh5]} style={[ From ca363a74d3d45a7811426fdf914d3b131fa0f8c0 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Mon, 13 Jul 2026 11:09:00 +0500 Subject: [PATCH 16/26] change handleKeyPress to submitFormOnModEnter --- .../workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx | 8 ++++---- .../workspace/rules/AgentRules/EditAgentRulePage.tsx | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx index a7e2732eb0eb..bd8a604ad0ca 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx @@ -33,11 +33,11 @@ function AddAgentRuleWriteTab({onSave}: AddAgentRuleWriteTabProps) { const shouldUseScrollableLayout = useIsInLandscapeMode(); const formRef = useRef(null); - const handleKeyPress = (e: TextInputKeyPressEvent | KeyboardEvent) => { - if (!('key' in e)) { + const submitFormOnModEnter = (event: TextInputKeyPressEvent | KeyboardEvent) => { + if (!('key' in event)) { return; } - if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { formRef.current?.submit(); } }; @@ -76,7 +76,7 @@ function AddAgentRuleWriteTab({onSave}: AddAgentRuleWriteTabProps) { inputID={INPUT_IDS.PROMPT} accessibilityLabel={translate('workspace.rules.agentRules.describeRuleHeadline')} role={CONST.ROLE.PRESENTATION} - onKeyPress={handleKeyPress} + onKeyPress={submitFormOnModEnter} multiline shouldSaveDraft containerStyles={[styles.flex1]} diff --git a/src/pages/workspace/rules/AgentRules/EditAgentRulePage.tsx b/src/pages/workspace/rules/AgentRules/EditAgentRulePage.tsx index 2a98be21bb03..398e489cb0d4 100644 --- a/src/pages/workspace/rules/AgentRules/EditAgentRulePage.tsx +++ b/src/pages/workspace/rules/AgentRules/EditAgentRulePage.tsx @@ -52,11 +52,11 @@ function EditAgentRulePage({ const agentRule = policy?.rules?.agentRules?.[ruleID]; const formRef = useRef(null); - const handleKeyPress = (e: TextInputKeyPressEvent | KeyboardEvent) => { - if (!('key' in e)) { + const submitFormOnModEnter = (event: TextInputKeyPressEvent | KeyboardEvent) => { + if (!('key' in event)) { return; } - if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { formRef.current?.submit(); } }; @@ -150,7 +150,7 @@ function EditAgentRulePage({ label={translate('workspace.rules.agentRules.describeRuleTitle')} accessibilityLabel={translate('workspace.rules.agentRules.describeRuleTitle')} role={CONST.ROLE.PRESENTATION} - onKeyPress={handleKeyPress} + onKeyPress={submitFormOnModEnter} defaultValue={agentRule.prompt} multiline shouldLabelStayOnSingleLine From a807dbefd13259767b1e41feb08ff3bf98d876a9 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Mon, 13 Jul 2026 11:24:32 +0500 Subject: [PATCH 17/26] Enhance loading and empty state handling in AddAgentRuleSuggestionsTab to account for offline status. Update tests to verify offline behavior and loading indicators. --- .../AgentRules/AddAgentRuleSuggestionsTab.tsx | 4 +-- tests/ui/AddAgentRuleSuggestionsTabTest.tsx | 28 +++++++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab.tsx index aab072589b52..d6042fa08509 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab.tsx @@ -54,8 +54,8 @@ function AddAgentRuleSuggestionsTab({onSelectSuggestion}: AddAgentRuleSuggestion const selectedSuggestion = filteredSuggestions.find((suggestion) => suggestion.id === selectedSuggestionID); const hasNoSuggestions = data.length === 0; - const shouldShowLoadingIndicator = isLoading && hasNoSuggestions; - const shouldShowEmptyState = !isLoading && hasNoSuggestions; + const shouldShowLoadingIndicator = isLoading && hasNoSuggestions && !isOffline; + const shouldShowEmptyState = hasNoSuggestions && (!isLoading || isOffline); const goToConcierge = () => { Navigation.navigate(ROUTES.CONCIERGE); diff --git a/tests/ui/AddAgentRuleSuggestionsTabTest.tsx b/tests/ui/AddAgentRuleSuggestionsTabTest.tsx index f0730e88a4fb..d548e3ccaafb 100644 --- a/tests/ui/AddAgentRuleSuggestionsTabTest.tsx +++ b/tests/ui/AddAgentRuleSuggestionsTabTest.tsx @@ -1,5 +1,6 @@ import {fireEvent, render, screen} from '@testing-library/react-native'; +import useNetwork from '@hooks/useNetwork'; import useSuggestedAgentRules from '@hooks/useSuggestedAgentRules'; import Navigation from '@libs/Navigation/Navigation'; @@ -28,7 +29,7 @@ jest.mock('@hooks/useLocalize', () => translate: (key: string) => key, })), ); -jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); +jest.mock('@hooks/useNetwork'); jest.mock('@hooks/useSuggestedAgentRules'); jest.mock('@hooks/useTheme', () => jest.fn(() => ({icon: '#000'}))); jest.mock('@hooks/useThemeStyles', () => @@ -45,7 +46,7 @@ jest.mock('@hooks/useThemeStyles', () => jest.mock('@libs/Navigation/Navigation', () => ({ navigate: jest.fn(), })); -jest.mock('@components/ActivityIndicator', () => jest.fn(() => null)); +jest.mock('@components/ActivityIndicator', () => jest.fn(() => )); jest.mock('@components/Icon', () => jest.fn(() => null)); jest.mock('@components/TextInput', () => ({value, onChangeText, label}: {value?: string; onChangeText?: (text: string) => void; label?: string}) => ( ({ jest.mock('@components/Text', () => ({children}: {children: React.ReactNode}) => {children}); const mockedUseSuggestedAgentRules = jest.mocked(useSuggestedAgentRules); +const mockedUseNetwork = jest.mocked(useNetwork); const mockedNavigate = jest.mocked(Navigation.navigate); const SUGGESTIONS: SuggestedAgentRule[] = [ @@ -109,6 +111,7 @@ const SUGGESTIONS: SuggestedAgentRule[] = [ describe('AddAgentRuleSuggestionsTab', () => { beforeEach(() => { jest.clearAllMocks(); + mockedUseNetwork.mockReturnValue({isOffline: false}); mockedUseSuggestedAgentRules.mockReturnValue({data: SUGGESTIONS, isLoading: false}); }); @@ -165,6 +168,7 @@ describe('AddAgentRuleSuggestionsTab', () => { mockedUseSuggestedAgentRules.mockReturnValue({data: [], isLoading: true}); render(); + expect(screen.getByTestId('suggestions-loading-indicator')).toBeOnTheScreen(); expect(screen.queryByText('workspace.rules.agentRules.emptySuggestionsTitle')).toBeNull(); }); @@ -175,4 +179,24 @@ describe('AddAgentRuleSuggestionsTab', () => { expect(screen.getByText('workspace.rules.agentRules.emptySuggestionsTitle')).toBeOnTheScreen(); expect(screen.getByText('workspace.rules.agentRules.emptySuggestionsSubtitle')).toBeOnTheScreen(); }); + + it('shows the offline empty state instead of a spinner when offline and still loading', () => { + mockedUseNetwork.mockReturnValue({isOffline: true}); + mockedUseSuggestedAgentRules.mockReturnValue({data: [], isLoading: true}); + render(); + + expect(screen.queryByTestId('suggestions-loading-indicator')).toBeNull(); + expect(screen.getByText('workspace.rules.agentRules.emptySuggestionsTitle')).toBeOnTheScreen(); + expect(screen.getByText('common.youAppearToBeOffline')).toBeOnTheScreen(); + }); + + it('shows the offline empty subtitle when offline with no suggestions', () => { + mockedUseNetwork.mockReturnValue({isOffline: true}); + mockedUseSuggestedAgentRules.mockReturnValue({data: [], isLoading: false}); + render(); + + expect(screen.getByText('workspace.rules.agentRules.emptySuggestionsTitle')).toBeOnTheScreen(); + expect(screen.getByText('common.youAppearToBeOffline')).toBeOnTheScreen(); + expect(screen.queryByText('workspace.rules.agentRules.emptySuggestionsSubtitle')).toBeNull(); + }); }); From b9aa6335043d0b66fac52ee13ec2c79e4de9840b Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Mon, 13 Jul 2026 20:39:21 +0500 Subject: [PATCH 18/26] use max height in edit tab text input field --- .../rules/AgentRules/AddAgentRuleWriteTab.tsx | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx index bd8a604ad0ca..f66fa14bd221 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx @@ -56,7 +56,7 @@ function AddAgentRuleWriteTab({onSave}: AddAgentRuleWriteTabProps) { formID={ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM} validate={validate} onSubmit={onSave} - submitButtonText={translate('common.next')} + submitButtonText={translate('common.save')} style={[styles.flex1, styles.ph5]} shouldUseScrollView={shouldUseScrollableLayout} submitFlexEnabled={shouldUseScrollableLayout ? undefined : false} @@ -69,24 +69,24 @@ function AddAgentRuleWriteTab({onSave}: AddAgentRuleWriteTabProps) { {translate('workspace.rules.agentRules.describeRuleHeadline')} {translate('workspace.rules.agentRules.describeRuleSubtitle')} - - - - - + + + {translate('workspace.rules.agentRules.disclaimer')} ); From d04f354641ab4c2ea55d24209528b0f9f8b4d598 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Wed, 15 Jul 2026 22:58:57 +0500 Subject: [PATCH 19/26] Implement review feedbacks --- .../rules/AgentRules/AddAgentRulePage.tsx | 52 ++++++++++++++++++- .../AgentRules/AddAgentRuleSuggestionsTab.tsx | 37 +++++-------- tests/ui/AddAgentRuleSuggestionsTabTest.tsx | 40 +++++++------- 3 files changed, 83 insertions(+), 46 deletions(-) diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx index a4f462ee9826..c2b4fbdbbea2 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx @@ -1,6 +1,7 @@ import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {BotAvatarBlue} from '@components/Icon/DefaultBotAvatars'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; import TabSelectorBase from '@components/TabSelector/TabSelectorBase'; @@ -8,9 +9,11 @@ import TabSelectorContextProvider from '@components/TabSelector/TabSelectorConte import type {TabSelectorBaseItem} from '@components/TabSelector/types'; import useConfirmModal from '@hooks/useConfirmModal'; +import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -70,8 +73,11 @@ function AddAgentRulePage({ const {showConfirmModal, closeModal} = useConfirmModal(); const [activeTab, setActiveTab] = useState(CONST.TAB.AGENT_RULE.SUGGESTIONS); const [activeTabPolicyID, setActiveTabPolicyID] = useState(policyID); + const [draftValues] = useOnyx(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM_DRAFT); const tabIcons = useMemoizedLazyExpensifyIcons(['Feed', 'Pencil']); + const hasDraftPrompt = !!draftValues?.[INPUT_IDS.PROMPT]?.trim(); + // Reset the active tab to Suggestions when the workspace changes. if (activeTabPolicyID !== policyID) { setActiveTabPolicyID(policyID); @@ -86,6 +92,11 @@ function AddAgentRulePage({ return () => clearDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM); }, [policyID, isOffline]); + const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ + getHasUnsavedChanges: () => hasDraftPrompt, + onConfirm: () => clearDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM), + }); + const tabs: TabSelectorBaseItem[] = [ { key: CONST.TAB.AGENT_RULE.SUGGESTIONS, @@ -116,10 +127,45 @@ function AddAgentRulePage({ Tab.setSelectedTab(CONST.TAB.AGENT_RULE_TAB_TYPE, key); }; + const leavePage = () => { + clearDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM); + Navigation.goBack(); + }; + + const confirmLeaveIfNeeded = () => { + if (!hasDraftPrompt) { + leavePage(); + return; + } + + showConfirmModal({ + title: translate('discardChangesConfirmation.title'), + prompt: translate('discardChangesConfirmation.body'), + danger: true, + confirmText: translate('discardChangesConfirmation.confirmText'), + cancelText: translate('common.cancel'), + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { + return; + } + leavePage(); + }); + }; + + const handleBackButtonPress = () => { + if (activeTab === CONST.TAB.AGENT_RULE.WRITE) { + setActiveTab(CONST.TAB.AGENT_RULE.SUGGESTIONS); + Tab.setSelectedTab(CONST.TAB.AGENT_RULE_TAB_TYPE, CONST.TAB.AGENT_RULE.SUGGESTIONS); + return; + } + confirmLeaveIfNeeded(); + }; + const saveRule = (values: FormOnyxValues): void => { // When the workspace has no agent rules yet, the backend creates the "RuleBot" agent and adds it as // an admin. Surface a one-time modal explaining this side effect before navigating back. const isFirstRule = isEmptyObject(policy?.rules?.agentRules); + suppressDiscardPrompt(); addPolicyAgentRule(policyID, rand64(), values[INPUT_IDS.PROMPT]); clearDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM); if (!isFirstRule) { @@ -176,7 +222,11 @@ function AddAgentRulePage({ includeSafeAreaPaddingBottom shouldEnableMaxHeight > - + { - Navigation.navigate(ROUTES.CONCIERGE); - }; - const goToEditWithSelection = () => { if (!selectedSuggestion) { return; @@ -85,11 +79,14 @@ function AddAgentRuleSuggestionsTab({onSelectSuggestion}: AddAgentRuleSuggestion icon={illustrations.Lightbulb} title={translate('workspace.rules.agentRules.emptySuggestionsTitle')} subtitle={isOffline ? translate('common.youAppearToBeOffline') : translate('workspace.rules.agentRules.emptySuggestionsSubtitle')} + subtitleStyle={[styles.textSupporting, styles.textNormal]} containerStyle={styles.flex1} /> ); } + const hasNoFilteredSuggestions = filteredSuggestions.length === 0; + return ( @@ -104,24 +101,22 @@ function AddAgentRuleSuggestionsTab({onSelectSuggestion}: AddAgentRuleSuggestion - {filteredSuggestions.length === 0 ? ( - + {hasNoFilteredSuggestions ? ( + + {translate('common.noResultsFound')} + ) : ( filteredSuggestions.map((suggestion) => { const iconName = getSuggestedAgentRuleIcon(suggestion); const isSelected = suggestion.id === selectedSuggestionID; + const suggestionLabel = suggestion.prompt ?? suggestion.title ?? ''; return ( setSelectedSuggestionID(suggestion.id)} @@ -135,6 +130,7 @@ function AddAgentRuleSuggestionsTab({onSelectSuggestion}: AddAgentRuleSuggestion styles.borderRadiusComponentNormal, isSelected && styles.activeComponentBG, ]} + hoverStyle={!isSelected ? styles.hoveredComponentBG : undefined} sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.SUGGESTED_AGENT_RULE} > - {suggestion.title} + {suggestionLabel} ); }) )} - - +