diff --git a/assets/images/avatars/bots/bot-avatar--blue.svg b/assets/images/avatars/bots/bot-avatar--blue.svg
new file mode 100644
index 000000000000..cd85fdae5acc
--- /dev/null
+++ b/assets/images/avatars/bots/bot-avatar--blue.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/avatars/bots/bot-avatar--green.svg b/assets/images/avatars/bots/bot-avatar--green.svg
new file mode 100644
index 000000000000..0d5601524791
--- /dev/null
+++ b/assets/images/avatars/bots/bot-avatar--green.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/avatars/bots/bot-avatar--ice.svg b/assets/images/avatars/bots/bot-avatar--ice.svg
new file mode 100644
index 000000000000..9c743ed39dd8
--- /dev/null
+++ b/assets/images/avatars/bots/bot-avatar--ice.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/avatars/bots/bot-avatar--pink.svg b/assets/images/avatars/bots/bot-avatar--pink.svg
new file mode 100644
index 000000000000..3b1a6a2325d1
--- /dev/null
+++ b/assets/images/avatars/bots/bot-avatar--pink.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/avatars/bots/bot-avatar--tangerine.svg b/assets/images/avatars/bots/bot-avatar--tangerine.svg
new file mode 100644
index 000000000000..c8b90de903e1
--- /dev/null
+++ b/assets/images/avatars/bots/bot-avatar--tangerine.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/avatars/bots/bot-avatar--yellow.svg b/assets/images/avatars/bots/bot-avatar--yellow.svg
new file mode 100644
index 000000000000..33d3c0799780
--- /dev/null
+++ b/assets/images/avatars/bots/bot-avatar--yellow.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index 002848e4747c..fae5916c7d7c 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -9539,6 +9539,9 @@ const CONST = {
SIGN_OUT: 'SettingsGeneral-SignOut',
GO_TO_CLASSIC: 'SettingsGeneral-GoToExpensifyClassic',
},
+ ADD_AGENT_PAGE: {
+ AVATAR: 'AddAgentPage-Avatar',
+ },
SETTINGS_PROFILE: {
AVATAR: 'SettingsProfile-Avatar',
DISPLAY_NAME: 'SettingsProfile-DisplayName',
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 6e7d9dc72238..d56f664119d6 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -1107,6 +1107,8 @@ const ONYXKEYS = {
ADD_WORK_EMAIL_FORM_DRAFT: 'addWorkEmailFormDraft',
EDIT_DOMAIN_GROUP_NAME_FORM: 'editDomainGroupNameForm',
EDIT_DOMAIN_GROUP_NAME_FORM_DRAFT: 'editDomainGroupNameFormDraft',
+ ADD_AGENT_FORM: 'addAgentForm',
+ ADD_AGENT_FORM_DRAFT: 'addAgentFormDraft',
},
DERIVED: {
REPORT_ATTRIBUTES: 'reportAttributes',
@@ -1252,6 +1254,7 @@ type OnyxFormValuesMapping = {
[ONYXKEYS.FORMS.ADD_DOMAIN_MEMBER_FORM]: FormTypes.AddDomainMemberForm;
[ONYXKEYS.FORMS.ADD_WORK_EMAIL_FORM]: FormTypes.AddWorkEmailForm;
[ONYXKEYS.FORMS.EDIT_DOMAIN_GROUP_NAME_FORM]: FormTypes.DomainGroupEditNameForm;
+ [ONYXKEYS.FORMS.ADD_AGENT_FORM]: FormTypes.AddAgentForm;
};
type OnyxFormDraftValuesMapping = {
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index e0c6d437a70f..97ebe8ebdaf1 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -870,6 +870,7 @@ const ROUTES = {
SETTINGS_WALLET_TRAVEL_CVV: 'settings/wallet/travel-cvv',
SETTINGS_WALLET_TRAVEL_CVV_VERIFY_ACCOUNT: `settings/wallet/travel-cvv/${VERIFY_ACCOUNT}`,
SETTINGS_AGENTS: 'settings/agents',
+ SETTINGS_AGENTS_ADD: 'settings/agents/new',
SETTINGS_RULES: 'settings/rules',
SETTINGS_RULES_ADD: {
route: 'settings/rules/new/:field?/:index?',
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index a7d812a73f59..5938bd77f006 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -172,6 +172,7 @@ const SCREENS = {
AGENTS: {
ROOT: 'Settings_Agents',
+ ADD: 'Settings_Agents_Add',
},
RULES: {
diff --git a/src/components/Icon/DefaultBotAvatars.ts b/src/components/Icon/DefaultBotAvatars.ts
new file mode 100644
index 000000000000..65a6cc5d6249
--- /dev/null
+++ b/src/components/Icon/DefaultBotAvatars.ts
@@ -0,0 +1,23 @@
+import type {TupleToUnion} from 'type-fest';
+import BotAvatarBlue from '@assets/images/avatars/bots/bot-avatar--blue.svg';
+import BotAvatarGreen from '@assets/images/avatars/bots/bot-avatar--green.svg';
+import BotAvatarIce from '@assets/images/avatars/bots/bot-avatar--ice.svg';
+import BotAvatarPink from '@assets/images/avatars/bots/bot-avatar--pink.svg';
+import BotAvatarTangerine from '@assets/images/avatars/bots/bot-avatar--tangerine.svg';
+import BotAvatarYellow from '@assets/images/avatars/bots/bot-avatar--yellow.svg';
+
+const botAvatars = [BotAvatarBlue, BotAvatarGreen, BotAvatarIce, BotAvatarPink, BotAvatarTangerine, BotAvatarYellow] as const;
+
+type BotAvatar = TupleToUnion;
+
+const botAvatarIDs = new Map([
+ [BotAvatarBlue, 'bot-avatar--blue'],
+ [BotAvatarGreen, 'bot-avatar--green'],
+ [BotAvatarIce, 'bot-avatar--ice'],
+ [BotAvatarPink, 'bot-avatar--pink'],
+ [BotAvatarTangerine, 'bot-avatar--tangerine'],
+ [BotAvatarYellow, 'bot-avatar--yellow'],
+]);
+
+export {BotAvatarBlue, BotAvatarGreen, BotAvatarIce, BotAvatarPink, BotAvatarTangerine, BotAvatarYellow, botAvatars, botAvatarIDs};
+export type {BotAvatar};
diff --git a/src/languages/de.ts b/src/languages/de.ts
index 99fc0b2e4460..61ec662eb02d 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -2725,6 +2725,19 @@ ${amount} für ${merchant} – ${date}`,
subtitle: 'Erstelle Agents, die deinen Workflow übernehmen. Spare dir die manuelle Arbeit und gewinne stundenweise Zeit im Alltag zurück.',
newAgent: 'Neue:r Agent:in',
emptyAgents: {title: 'Keine Agenten erstellt', subtitle: 'Hör auf, Dinge manuell zu erledigen. Weise stattdessen eine:n Agent:in an und spare dir eine Menge Zeit.'},
+ error: {
+ genericAdd: 'Beim Hinzufügen dieses Agenten ist ein Problem aufgetreten',
+ },
+ },
+ addAgentPage: {
+ title: 'Neue Kontaktperson',
+ agentName: 'Name der Ansprechperson',
+ instructions: 'Eigene Anweisungen schreiben',
+ createAgent: 'Agent erstellen',
+ switchAvatar: 'Profilbild wechseln',
+ defaultAgentName: (displayName: string) => `Agent*in von ${displayName}`,
+ defaultPrompt:
+ 'Lehne Ausgaben ab, die für Glücksspiele, Kinobesuche oder andere offensichtlich nicht geschäftliche Zwecke sind.\n\nErinnere den:die Nutzer:in daran, immer ein Belegfoto beizufügen, auf dem das Trinkgeld klar erkennbar ist.\n\nGenehmige den Bericht, wenn er früheren Berichten derselben Person sehr ähnlich ist.\n\nLehne Berichte mit mehr als 500 $ an Reisekosten ab.',
},
expenseRulesPage: {
title: 'Ausgabenregeln',
diff --git a/src/languages/en.ts b/src/languages/en.ts
index ba38d8931813..d500ec970a77 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -2782,6 +2782,19 @@ const translations = {
title: 'No agents created',
subtitle: 'Stop manually doing stuff. Instruct an agent instead and save yourself lots of time.',
},
+ error: {
+ genericAdd: 'There was a problem adding this agent',
+ },
+ },
+ addAgentPage: {
+ title: 'New agent',
+ agentName: 'Agent name',
+ instructions: 'Write custom instructions',
+ createAgent: 'Create agent',
+ switchAvatar: 'Switch avatar',
+ defaultAgentName: (displayName: string) => `${displayName}'s Agent`,
+ defaultPrompt:
+ "Reject expenses that are for gambling, movies, or other obvious non-business reasons.\n\nRemind the user to always include a receipt image that makes the tip clear.\n\nApprove the report if it's very similar to previous reports from the same user.\n\nReject reports with more than $500 in travel expenses.",
},
expenseRulesPage: {
title: 'Expense rules',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 745b29b85a84..77fcacfe8ddd 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -2617,6 +2617,19 @@ ${amount} para ${merchant} - ${date}`,
title: 'No se han creado agentes.',
subtitle: 'Deja de hacer las cosas manualmente. En su lugar, instruye a un agente y ahorra mucho tiempo.',
},
+ error: {
+ genericAdd: 'Hubo un problema al agregar este agente',
+ },
+ },
+ addAgentPage: {
+ title: 'Nuevo agente',
+ agentName: 'Nombre del agente',
+ instructions: 'Escribe instrucciones personalizadas',
+ createAgent: 'Crear agente',
+ switchAvatar: 'Cambiar avatar',
+ defaultAgentName: (displayName: string) => `Agente de ${displayName}`,
+ defaultPrompt:
+ 'Rechazar gastos por juegos de azar, películas u otras razones claramente no comerciales.\n\nRecordar al usuario que siempre incluya una imagen del recibo que muestre claramente la propina.\n\nAprobar el informe si es muy similar a informes anteriores del mismo usuario.\n\nRechazar informes con más de $500 en gastos de viaje.',
},
expenseRulesPage: {
title: 'Reglas de gastos',
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index 2d3c7d1dbbc7..88071173742b 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -2731,6 +2731,19 @@ ${amount} pour ${merchant} - ${date}`,
subtitle: 'Créez des agents pour gérer votre flux de travail. Évitez le travail manuel et gagnez des heures dans votre journée.',
newAgent: 'Nouvel agent',
emptyAgents: {title: 'Aucun agent créé', subtitle: 'Arrêtez de faire les choses manuellement. Donnez plutôt des instructions à un agent et gagnez beaucoup de temps.'},
+ error: {
+ genericAdd: "Un problème est survenu lors de l'ajout de cet agent",
+ },
+ },
+ addAgentPage: {
+ title: 'Nouvel agent',
+ agentName: 'Nom de l’agent',
+ instructions: 'Rédiger des instructions personnalisées',
+ createAgent: 'Créer un agent',
+ switchAvatar: "Changer d'avatar",
+ defaultAgentName: (displayName: string) => `Agent de ${displayName}`,
+ defaultPrompt:
+ 'Rejeter les dépenses liées aux jeux d’argent, aux films ou à d’autres motifs manifestement non professionnels.\n\nRappeler à l’utilisateur d’inclure systématiquement une image du reçu où le pourboire est clairement visible.\n\nApprouver le rapport s’il est très similaire aux rapports précédents du même utilisateur.\n\nRejeter les rapports contenant plus de 500 $ de frais de déplacement.',
},
expenseRulesPage: {
title: 'Règles de dépenses',
diff --git a/src/languages/it.ts b/src/languages/it.ts
index 06bbc1f28549..becf9940239a 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -2721,6 +2721,19 @@ ${amount} per ${merchant} - ${date}`,
subtitle: 'Crea agenti per gestire il tuo flusso di lavoro. Elimina il lavoro manuale e recupera ore della tua giornata.',
newAgent: 'Nuovo agente',
emptyAgents: {title: 'Nessun agente creato', subtitle: 'Smetti di fare le cose manualmente. Dai istruzioni a un agente e risparmia un sacco di tempo.'},
+ error: {
+ genericAdd: "Si è verificato un problema durante l'aggiunta di questo agente",
+ },
+ },
+ addAgentPage: {
+ title: 'Nuovo agente',
+ agentName: 'Nome agente',
+ instructions: 'Scrivi istruzioni personalizzate',
+ createAgent: 'Crea agente',
+ switchAvatar: 'Cambia avatar',
+ defaultAgentName: (displayName: string) => `Agente di ${displayName}`,
+ defaultPrompt:
+ "Rifiuta le spese relative a gioco d'azzardo, cinema o altri motivi chiaramente non legati all'attività.\n\nRicorda all'utente di includere sempre un'immagine della ricevuta in cui la mancia sia ben visibile.\n\nApprova il report se è molto simile ai report precedenti dello stesso utente.\n\nRifiuta i report con più di 500 $ di spese di viaggio.",
},
expenseRulesPage: {
title: 'Regole spese',
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index ac5a03f90794..ee61e771c11f 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -2694,6 +2694,19 @@ ${date} の ${merchant} への ${amount}`,
subtitle: 'ワークフローを処理するエージェントを作成しましょう。手作業を省いて、1日の時間を何時間も取り戻せます。',
newAgent: '新しいエージェント',
emptyAgents: {title: 'エージェントは作成されていません', subtitle: '手作業はやめて、代わりにエージェントに指示を出して、時間を大幅に節約しましょう。'},
+ error: {
+ genericAdd: 'このエージェントの追加中に問題が発生しました',
+ },
+ },
+ addAgentPage: {
+ title: '新しいエージェント',
+ agentName: 'エージェント名',
+ instructions: 'カスタム指示を作成',
+ createAgent: 'エージェントを作成',
+ switchAvatar: 'アバターを切り替え',
+ defaultAgentName: (displayName: string) => `${displayName} さんの代理人`,
+ defaultPrompt:
+ 'ギャンブル、映画、またはその他明らかにビジネス目的ではない理由による経費は却下します。\n\nチップの金額が明確にわかるレシート画像を必ず添付するよう、ユーザーにリマインドします。\n\n同じユーザーの過去のレポートと非常によく似ている場合は、そのレポートを承認します。\n\n出張費が500ドルを超えるレポートは却下します。',
},
expenseRulesPage: {
title: '経費ルール',
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index 5cbb18b520ad..9d78881fa714 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -2718,6 +2718,19 @@ ${amount} voor ${merchant} - ${date}`,
subtitle: 'Maak agents aan om je workflow af te handelen. Sla het handmatige werk over en krijg uren van je dag terug.',
newAgent: 'Nieuwe medewerker',
emptyAgents: {title: 'Geen agents aangemaakt', subtitle: 'Stop met dingen handmatig doen. Geef in plaats daarvan een opdracht aan een agent en bespaar jezelf veel tijd.'},
+ error: {
+ genericAdd: 'Er was een probleem bij het toevoegen van deze agent',
+ },
+ },
+ addAgentPage: {
+ title: 'Nieuwe agent',
+ agentName: 'Naam medewerker',
+ instructions: 'Schrijf aangepaste instructies',
+ createAgent: 'Agent aanmaken',
+ switchAvatar: 'Profielavatar wisselen',
+ defaultAgentName: (displayName: string) => `Agent van ${displayName}`,
+ defaultPrompt:
+ 'Wijs declaraties af die zijn voor gokken, films of andere duidelijk niet-zakelijke redenen.\n\nHerinner de gebruiker eraan altijd een bonafbeelding toe te voegen waarop de fooi duidelijk is.\n\nKeur het verslag goed als het sterk lijkt op eerdere verslagen van dezelfde gebruiker.\n\nWijs verslagen af met meer dan $500 aan reiskosten.',
},
expenseRulesPage: {
title: 'Declaratieregels',
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index c5288fd5c935..77d132180a97 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -2712,6 +2712,19 @@ ${amount} dla ${merchant} - ${date}`,
subtitle: 'Twórz agentów do obsługi swojego przepływu pracy. Pomiń ręczną pracę i odzyskaj godziny w ciągu dnia.',
newAgent: 'Nowy agent',
emptyAgents: {title: 'Nie utworzono agentów', subtitle: 'Przestań robić wszystko ręcznie. Zamiast tego wydaj polecenia agentowi i zaoszczędź mnóstwo czasu.'},
+ error: {
+ genericAdd: 'Wystąpił problem podczas dodawania tego agenta',
+ },
+ },
+ addAgentPage: {
+ title: 'Nowy agent',
+ agentName: 'Nazwa agenta',
+ instructions: 'Napisz własne instrukcje',
+ createAgent: 'Utwórz agenta',
+ switchAvatar: 'Zmień awatar',
+ defaultAgentName: (displayName: string) => `Agent ${displayName}`,
+ defaultPrompt:
+ 'Odrzucaj wydatki związane z hazardem, filmami lub innymi oczywistymi celami niezwiązanymi z działalnością biznesową.\n\nPrzypominaj użytkownikowi, aby zawsze dołączał zdjęcie paragonu, na którym wysokość napiwku jest wyraźnie widoczna.\n\nZatwierdź raport, jeśli jest bardzo podobny do wcześniejszych raportów tego samego użytkownika.\n\nOdrzucaj raporty zawierające więcej niż 500 USD wydatków na podróże.',
},
expenseRulesPage: {
title: 'Reguły wydatków',
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index 7dc9be735efe..3f8aa2dbef96 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -2712,6 +2712,19 @@ ${amount} para ${merchant} - ${date}`,
subtitle: 'Crie agentes para gerenciar seu fluxo de trabalho. Pule o trabalho manual e ganhe horas de volta no seu dia.',
newAgent: 'Novo agente',
emptyAgents: {title: 'Nenhum agente criado', subtitle: 'Pare de fazer tudo manualmente. Instrua um agente e economize muito tempo.'},
+ error: {
+ genericAdd: 'Houve um problema ao adicionar este agente',
+ },
+ },
+ addAgentPage: {
+ title: 'Novo agente',
+ agentName: 'Nome do agente',
+ instructions: 'Escrever instruções personalizadas',
+ createAgent: 'Criar agente',
+ switchAvatar: 'Trocar avatar',
+ defaultAgentName: (displayName: string) => `Agente de ${displayName}`,
+ defaultPrompt:
+ 'Rejeite despesas relacionadas a jogos de azar, cinema ou outros motivos claramente não relacionados ao negócio.\n\nLembre o usuário de sempre incluir uma imagem do recibo em que a gorjeta fique clara.\n\nAprove o relatório se ele for muito semelhante a relatórios anteriores do mesmo usuário.\n\nRejeite relatórios com mais de US$ 500 em despesas de viagem.',
},
expenseRulesPage: {
title: 'Regras de despesas',
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index 391e1db13561..b2cb2f9436d9 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -2644,6 +2644,19 @@ ${amount},商户:${merchant} - 日期:${date}`,
subtitle: '通过自定义智能体自动化处理任务。',
newAgent: '新代理人',
emptyAgents: {title: '尚未创建代理', subtitle: '别再手动处理这些事情了。交给智能代理去执行,为自己节省大量时间。'},
+ error: {
+ genericAdd: '添加此智能体时出现了问题',
+ },
+ },
+ addAgentPage: {
+ title: '新代理',
+ agentName: '代理名称',
+ instructions: '编写自定义说明',
+ createAgent: '创建代理',
+ switchAvatar: '切换头像',
+ defaultAgentName: (displayName: string) => `${displayName} 的代理人`,
+ defaultPrompt:
+ '拒绝与赌博、电影或其他明显非商务原因相关的报销。\n\n提醒用户务必附上一张能清楚显示小费金额的收据图片。\n\n如果报销报告与同一用户之前的报告非常相似,则批准该报告。\n\n拒绝包含超过 500 美元差旅费用的报销报告。',
},
expenseRulesPage: {
title: '报销规则',
diff --git a/src/libs/API/parameters/CreateAgentParams.ts b/src/libs/API/parameters/CreateAgentParams.ts
new file mode 100644
index 000000000000..e06ada32618e
--- /dev/null
+++ b/src/libs/API/parameters/CreateAgentParams.ts
@@ -0,0 +1,7 @@
+type CreateAgentParams = {
+ firstName?: string;
+ customExpensifyAvatarID?: string;
+ prompt: string;
+};
+
+export default CreateAgentParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index 2f83fe5583d5..819c40ade1b9 100644
--- a/src/libs/API/parameters/index.ts
+++ b/src/libs/API/parameters/index.ts
@@ -521,3 +521,4 @@ export type {default as InitiateBankAccountUnlockParams} from './InitiateBankAcc
export type {default as UpdateDomainSecurityGroupParams} from './UpdateDomainSecurityGroupParams';
export type {default as SetDefaultDomainSecurityGroupParams} from './SetDefaultDomainSecurityGroupParams';
export type {default as DeleteDomainSecurityGroupParams} from './DeleteDomainSecurityGroupParams';
+export type {default as CreateAgentParams} from './CreateAgentParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index 30f580fb0648..ecbdce188127 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -586,6 +586,7 @@ const WRITE_COMMANDS = {
UPDATE_DOMAIN_SECURITY_GROUP: 'UpdateDomainSecurityGroupForNewDot',
SET_DEFAULT_DOMAIN_SECURITY_GROUP: 'SetDefaultDomainSecurityGroup',
DELETE_DOMAIN_SECURITY_GROUP: 'DeleteDomainSecurityGroup',
+ CREATE_AGENT: 'CreateAgent',
} as const;
type WriteCommand = ValueOf;
@@ -1189,6 +1190,7 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.UPDATE_DOMAIN_SECURITY_GROUP]: Parameters.UpdateDomainSecurityGroupParams;
[WRITE_COMMANDS.SET_DEFAULT_DOMAIN_SECURITY_GROUP]: Parameters.SetDefaultDomainSecurityGroupParams;
[WRITE_COMMANDS.DELETE_DOMAIN_SECURITY_GROUP]: Parameters.DeleteDomainSecurityGroupParams;
+ [WRITE_COMMANDS.CREATE_AGENT]: Parameters.CreateAgentParams;
};
const READ_COMMANDS = {
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
index b10ec42b3f04..b104468662c2 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
@@ -466,6 +466,7 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/settings/Wallet/InternationalDepositAccount/CountrySelectionVerifyAccountPage').default,
[SCREENS.SETTINGS.BANK_ACCOUNT_PURPOSE]: () => require('../../../../pages/settings/Wallet/BankAccountPurposePage').default,
[SCREENS.SETTINGS.RULES.ROOT]: () => require('../../../../pages/settings/Rules/ExpenseRulesPage').default,
+ [SCREENS.SETTINGS.AGENTS.ADD]: () => require('../../../../pages/settings/Agents/AddAgentPage').default,
[SCREENS.SETTINGS.RULES.ADD]: () => require('../../../../pages/settings/Rules/AddRulePage').default,
[SCREENS.SETTINGS.RULES.ADD_MERCHANT]: () => require('../../../../pages/settings/Rules/Fields/AddMerchantPage').default,
[SCREENS.SETTINGS.RULES.ADD_RENAME_MERCHANT]: () => require('../../../../pages/settings/Rules/Fields/AddRenameMerchantPage').default,
diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts
index 0abe30ed3b2d..763dc052e74b 100755
--- a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts
+++ b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts
@@ -70,6 +70,7 @@ const SETTINGS_TO_RHP: Partial['config'] = {
path: ROUTES.SETTINGS_BANK_ACCOUNT_PURPOSE,
exact: true,
},
+ [SCREENS.SETTINGS.AGENTS.ADD]: {
+ path: ROUTES.SETTINGS_AGENTS_ADD,
+ exact: true,
+ },
[SCREENS.SETTINGS.RULES.ADD]: {
path: ROUTES.SETTINGS_RULES_ADD.route,
exact: true,
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 29e26555278a..1c15e66269c8 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -256,6 +256,7 @@ type SettingsNavigatorParamList = {
};
[SCREENS.SETTINGS.BANK_ACCOUNT_PURPOSE]: undefined;
[SCREENS.SETTINGS.ADD_BANK_ACCOUNT_SELECT_COUNTRY_VERIFY_ACCOUNT]: undefined;
+ [SCREENS.SETTINGS.AGENTS.ADD]: undefined;
[SCREENS.SETTINGS.RULES.ADD]: undefined;
[SCREENS.SETTINGS.RULES.ADD_MERCHANT]: undefined;
[SCREENS.SETTINGS.RULES.ADD_RENAME_MERCHANT]: undefined;
diff --git a/src/libs/actions/Agent.ts b/src/libs/actions/Agent.ts
index 19e601b172bf..26454b7cc88f 100644
--- a/src/libs/actions/Agent.ts
+++ b/src/libs/actions/Agent.ts
@@ -1,9 +1,81 @@
-import {read} from '@libs/API';
-import {READ_COMMANDS} from '@libs/API/types';
+import Onyx from 'react-native-onyx';
+import {read, write} from '@libs/API';
+import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
+import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {AnyOnyxUpdate} from '@src/types/onyx/Request';
function openAgentsPage() {
read(READ_COMMANDS.OPEN_AGENTS_PAGE, null);
}
-// eslint-disable-next-line import/prefer-default-export
-export {openAgentsPage};
+function createAgent(firstName: string | undefined, prompt: string, customExpensifyAvatarID?: string) {
+ const optimisticAccountID = -Math.round(Math.random() * 1000000);
+
+ const optimisticData: AnyOnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.PERSONAL_DETAILS_LIST,
+ value: {
+ [optimisticAccountID]: {
+ accountID: optimisticAccountID,
+ displayName: firstName,
+ isOptimisticPersonalDetail: true,
+ },
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`,
+ value: {prompt, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD},
+ },
+ ];
+
+ const successData: AnyOnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.PERSONAL_DETAILS_LIST,
+ value: {
+ [optimisticAccountID]: null,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`,
+ value: null,
+ },
+ ];
+
+ const failureData: AnyOnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.PERSONAL_DETAILS_LIST,
+ value: {
+ [optimisticAccountID]: {
+ accountID: optimisticAccountID,
+ displayName: firstName,
+ isOptimisticPersonalDetail: true,
+ },
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`,
+ value: {
+ prompt,
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ errors: getMicroSecondOnyxErrorWithTranslationKey('agentsPage.error.genericAdd'),
+ },
+ },
+ ];
+
+ write(WRITE_COMMANDS.CREATE_AGENT, {firstName, prompt, customExpensifyAvatarID}, {optimisticData, successData, failureData});
+}
+
+function clearAgentError(optimisticAccountID: number) {
+ Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[optimisticAccountID]: null});
+ Onyx.set(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`, null);
+}
+
+export {openAgentsPage, createAgent, clearAgentError};
diff --git a/src/pages/settings/Agents/AddAgentPage.tsx b/src/pages/settings/Agents/AddAgentPage.tsx
new file mode 100644
index 000000000000..a2ac09a91978
--- /dev/null
+++ b/src/pages/settings/Agents/AddAgentPage.tsx
@@ -0,0 +1,119 @@
+import React, {useState} from 'react';
+import {View} from 'react-native';
+import AvatarButtonWithIcon from '@components/AvatarButtonWithIcon';
+import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
+import type {FormOnyxValues} from '@components/Form/types';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import type {BotAvatar} from '@components/Icon/DefaultBotAvatars';
+import {botAvatarIDs, botAvatars} from '@components/Icon/DefaultBotAvatars';
+import ScreenWrapper from '@components/ScreenWrapper';
+import TextInput from '@components/TextInput';
+import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
+import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@libs/Navigation/Navigation';
+import {createAgent} from '@userActions/Agent';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import INPUT_IDS from '@src/types/form/AddAgentForm';
+import type {Errors} from '@src/types/onyx/OnyxCommon';
+
+function AddAgentPage() {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const {displayName} = useCurrentUserPersonalDetails();
+ const defaultAgentName = displayName ? translate('addAgentPage.defaultAgentName', displayName) : undefined;
+ const defaultPrompt = translate('addAgentPage.defaultPrompt');
+ const expensifyIcons = useMemoizedLazyExpensifyIcons(['Sync']);
+ const avatarStyle = [styles.avatarXLarge, styles.alignSelfCenter];
+ const [avatarSource, setAvatarSource] = useState(() => botAvatars[Math.floor(Math.random() * botAvatars.length)]);
+
+ const handleSwitchAvatar = () => {
+ setAvatarSource((prev: BotAvatar) => botAvatars[(botAvatars.indexOf(prev) + 1) % botAvatars.length]);
+ };
+
+ const validate = (values: FormOnyxValues): Errors => {
+ const errors: Errors = {};
+ if (!values[INPUT_IDS.PROMPT].trim()) {
+ errors[INPUT_IDS.PROMPT] = translate('common.error.fieldRequired');
+ }
+ return errors;
+ };
+
+ const handleSubmit = (values: FormOnyxValues) => {
+ const firstName = values[INPUT_IDS.FIRST_NAME].trim() || undefined;
+ const customExpensifyAvatarID = botAvatarIDs.get(avatarSource);
+ createAgent(firstName, values[INPUT_IDS.PROMPT].trim(), customExpensifyAvatarID);
+ Navigation.goBack();
+ };
+
+ return (
+
+ Navigation.goBack()}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+AddAgentPage.displayName = 'AddAgentPage';
+
+export default AddAgentPage;
diff --git a/src/pages/settings/Agents/AgentsListRow.tsx b/src/pages/settings/Agents/AgentsListRow.tsx
index 1892263d397b..ee3b1e332f2a 100644
--- a/src/pages/settings/Agents/AgentsListRow.tsx
+++ b/src/pages/settings/Agents/AgentsListRow.tsx
@@ -1,11 +1,13 @@
import React from 'react';
import {View} from 'react-native';
+import OfflineWithFeedback from '@components/OfflineWithFeedback';
import ReportActionAvatars from '@components/ReportActionAvatars';
import Text from '@components/Text';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
import CONST from '@src/CONST';
+import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon';
type AgentsListRowProps = {
/** Account ID of the agent */
@@ -16,36 +18,51 @@ type AgentsListRowProps = {
/** Login email of the agent */
login: string;
+
+ /** Pending action for offline feedback */
+ pendingAction?: PendingAction | null;
+
+ /** Errors to display on the row */
+ errors?: Errors | null;
+
+ /** Called when the user dismisses the error */
+ onErrorClose?: () => void;
};
-function AgentsListRow({accountID, displayName, login}: AgentsListRowProps) {
+function AgentsListRow({accountID, displayName, login, pendingAction, errors, onErrorClose}: AgentsListRowProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
return (
-
-
-
-
- {displayName}
-
-
- {login}
-
+
+
+
+
+
+ {displayName}
+
+
+ {login}
+
+
- {/* Action buttons are deferred to R1.3 per phased rollout plan */}
-
+
);
}
diff --git a/src/pages/settings/Agents/AgentsPage.tsx b/src/pages/settings/Agents/AgentsPage.tsx
index 74e1d6de0e72..a1b0e3198159 100644
--- a/src/pages/settings/Agents/AgentsPage.tsx
+++ b/src/pages/settings/Agents/AgentsPage.tsx
@@ -16,15 +16,19 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
-import {openAgentsPage} from '@userActions/Agent';
+import {clearAgentError, openAgentsPage} from '@userActions/Agent';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon';
import AgentsListRow from './AgentsListRow';
type AgentItem = {
accountID: number;
displayName: string;
login: string;
+ pendingAction?: PendingAction | null;
+ errors?: Errors | null;
};
function AgentsPage() {
@@ -47,8 +51,8 @@ function AgentsPage() {
openAgentsPage();
}, [isCustomAgentEnabled]);
- const agentItems: AgentItem[] = Object.keys(agentPrompts ?? {})
- .map((key) => {
+ const agentItems: AgentItem[] = Object.entries(agentPrompts ?? {})
+ .map(([key, agentPrompt]) => {
const accountID = Number(key.slice(ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT.length));
const details = personalDetailsList?.[accountID];
if (!details) {
@@ -58,15 +62,20 @@ function AgentsPage() {
accountID,
displayName: details.displayName ?? details.login ?? '',
login: details.login ?? '',
+ pendingAction: agentPrompt?.pendingAction,
+ errors: agentPrompt?.errors,
};
})
- .filter((item): item is AgentItem => item !== null);
+ .filter(Boolean) as AgentItem[];
const renderItem = ({item}: {item: AgentItem}) => (
clearAgentError(item.accountID)}
/>
);
@@ -79,7 +88,7 @@ function AgentsPage() {
success
icon={icons.Plus}
text={translate('agentsPage.newAgent')}
- onPress={() => {}}
+ onPress={() => Navigation.navigate(ROUTES.SETTINGS_AGENTS_ADD)}
/>
);
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 74ec3a78ef42..9f54c2ed1efb 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -441,6 +441,10 @@ const staticStyles = (theme: ThemeColors) =>
verticalAlign: 'top',
},
+ textAlignVerticalTop: {
+ textAlignVertical: 'top',
+ },
+
lineHeightUndefined: {
lineHeight: undefined,
},
diff --git a/src/types/form/AddAgentForm.ts b/src/types/form/AddAgentForm.ts
new file mode 100644
index 000000000000..746fd62d69f2
--- /dev/null
+++ b/src/types/form/AddAgentForm.ts
@@ -0,0 +1,20 @@
+import type {ValueOf} from 'type-fest';
+import type Form from './Form';
+
+const INPUT_IDS = {
+ FIRST_NAME: 'firstName',
+ PROMPT: 'prompt',
+} as const;
+
+type InputID = ValueOf;
+
+type AddAgentForm = Form<
+ InputID,
+ {
+ [INPUT_IDS.FIRST_NAME]: string;
+ [INPUT_IDS.PROMPT]: string;
+ }
+>;
+
+export type {AddAgentForm};
+export default INPUT_IDS;
diff --git a/src/types/form/index.ts b/src/types/form/index.ts
index 58ef8b698fb7..840e93ebff0e 100644
--- a/src/types/form/index.ts
+++ b/src/types/form/index.ts
@@ -123,3 +123,4 @@ export type {DomainGroupEditNameForm} from './DomainGroupEditNameForm';
export type {WorkspaceTimeTrackingDefaultRateForm} from './WorkspaceTimeTrackingDefaultRateForm';
export type {EditExpensifyCardLimitTypeForm} from './EditExpensifyCardLimitTypeForm';
export type {AddWorkEmailForm} from './AddWorkEmailForm';
+export type {AddAgentForm} from './AddAgentForm';
diff --git a/src/types/onyx/AgentPrompt.ts b/src/types/onyx/AgentPrompt.ts
index e2cc72b56159..c89840c2a81c 100644
--- a/src/types/onyx/AgentPrompt.ts
+++ b/src/types/onyx/AgentPrompt.ts
@@ -1,7 +1,12 @@
+import type {Errors, OnyxValueWithOfflineFeedback} from './OnyxCommon';
+
/** Model of an agent's prompt data stored as a shared NVP */
-type AgentPrompt = {
+type AgentPrompt = OnyxValueWithOfflineFeedback<{
/** The system prompt defining the agent's behavior */
prompt: string;
-};
+
+ /** Errors from the last failed action */
+ errors?: Errors | null;
+}>;
export default AgentPrompt;
diff --git a/tests/unit/AgentActionTest.ts b/tests/unit/AgentActionTest.ts
new file mode 100644
index 000000000000..f574f64f4b7c
--- /dev/null
+++ b/tests/unit/AgentActionTest.ts
@@ -0,0 +1,138 @@
+import {write} from '@libs/API';
+import {WRITE_COMMANDS} from '@libs/API/types';
+import {createAgent} from '@userActions/Agent';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {AnyOnyxUpdate} from '@src/types/onyx/Request';
+
+jest.mock('@libs/API');
+
+const mockWrite = jest.mocked(write);
+
+function getWriteOptions(): {optimisticData: AnyOnyxUpdate[]; successData: AnyOnyxUpdate[]; failureData: AnyOnyxUpdate[]} {
+ const options = mockWrite.mock.calls.at(0)?.at(2);
+ if (!options || typeof options !== 'object' || !('optimisticData' in options)) {
+ throw new Error('write was not called with optimistic options');
+ }
+ return options as {optimisticData: AnyOnyxUpdate[]; successData: AnyOnyxUpdate[]; failureData: AnyOnyxUpdate[]};
+}
+
+function getOptimisticAccountID(optimisticData: AnyOnyxUpdate[]): string {
+ const personalDetailUpdate = optimisticData.find((u) => u.key === ONYXKEYS.PERSONAL_DETAILS_LIST);
+ if (!personalDetailUpdate?.value || typeof personalDetailUpdate.value !== 'object') {
+ throw new Error('No personal detail update in optimisticData');
+ }
+ return Object.keys(personalDetailUpdate.value as Record).at(0) ?? '';
+}
+
+describe('createAgent', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('calls write with CREATE_AGENT command and provided params', () => {
+ createAgent('My Agent', 'Reject gambling expenses.');
+
+ expect(mockWrite).toHaveBeenCalledWith(WRITE_COMMANDS.CREATE_AGENT, {firstName: 'My Agent', prompt: 'Reject gambling expenses.'}, expect.any(Object));
+ });
+
+ it('passes undefined firstName through unchanged', () => {
+ createAgent(undefined, 'Some prompt');
+
+ expect(mockWrite).toHaveBeenCalledWith(WRITE_COMMANDS.CREATE_AGENT, {firstName: undefined, prompt: 'Some prompt'}, expect.any(Object));
+ });
+
+ it('optimistic personal detail entry has a negative account ID', () => {
+ createAgent('Bot', 'My prompt');
+
+ const {optimisticData} = getWriteOptions();
+ const accountID = getOptimisticAccountID(optimisticData);
+
+ expect(Number(accountID)).toBeLessThan(0);
+ });
+
+ it('optimistic personal detail entry stores displayName and marks entry as optimistic', () => {
+ createAgent('Bot', 'My prompt');
+
+ const {optimisticData} = getWriteOptions();
+ const personalDetailUpdate = optimisticData.find((u) => u.key === ONYXKEYS.PERSONAL_DETAILS_LIST);
+ const accountID = getOptimisticAccountID(optimisticData);
+
+ expect((personalDetailUpdate?.value as Record)[accountID]).toMatchObject({
+ displayName: 'Bot',
+ isOptimisticPersonalDetail: true,
+ });
+ });
+
+ it('optimistic personal detail entry stores undefined displayName when firstName is undefined', () => {
+ createAgent(undefined, 'My prompt');
+
+ const {optimisticData} = getWriteOptions();
+ const personalDetailUpdate = optimisticData.find((u) => u.key === ONYXKEYS.PERSONAL_DETAILS_LIST);
+ const accountID = getOptimisticAccountID(optimisticData);
+
+ expect((personalDetailUpdate?.value as Record)[accountID]).toMatchObject({
+ displayName: undefined,
+ isOptimisticPersonalDetail: true,
+ });
+ });
+
+ it('optimistic prompt entry uses the same account ID as the personal detail entry', () => {
+ createAgent('Bot', 'My prompt');
+
+ const {optimisticData} = getWriteOptions();
+ const accountID = getOptimisticAccountID(optimisticData);
+ const promptUpdate = optimisticData.find((u) => u.key === `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`);
+
+ expect(promptUpdate?.value).toEqual({
+ prompt: 'My prompt',
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ });
+ });
+
+ it('does not merge ADD_AGENT_FORM (navigation handles UX after submit)', () => {
+ createAgent('Bot', 'My prompt');
+
+ const {optimisticData, successData, failureData} = getWriteOptions();
+
+ expect(optimisticData.some((u) => u.key === ONYXKEYS.FORMS.ADD_AGENT_FORM)).toBe(false);
+ expect(successData.some((u) => u.key === ONYXKEYS.FORMS.ADD_AGENT_FORM)).toBe(false);
+ expect(failureData.some((u) => u.key === ONYXKEYS.FORMS.ADD_AGENT_FORM)).toBe(false);
+ });
+
+ it('success data nulls out both optimistic entries', () => {
+ createAgent('Bot', 'My prompt');
+
+ const {optimisticData, successData} = getWriteOptions();
+ const accountID = getOptimisticAccountID(optimisticData);
+
+ const personalDetailRollback = successData.find((u) => u.key === ONYXKEYS.PERSONAL_DETAILS_LIST);
+ const promptRollback = successData.find((u) => u.key === `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`);
+
+ expect((personalDetailRollback?.value as Record)[accountID]).toBeNull();
+ expect(promptRollback?.value).toBeNull();
+ });
+
+ it('failure data preserves optimistic personal detail and merges errors onto the prompt entry', () => {
+ createAgent('Bot', 'My prompt');
+
+ const {optimisticData, failureData} = getWriteOptions();
+ const accountID = getOptimisticAccountID(optimisticData);
+
+ const personalDetailRollback = failureData.find((u) => u.key === ONYXKEYS.PERSONAL_DETAILS_LIST);
+ const promptRollback = failureData.find((u) => u.key === `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`);
+
+ expect((personalDetailRollback?.value as Record)[accountID]).toMatchObject({
+ accountID: Number(accountID),
+ displayName: 'Bot',
+ isOptimisticPersonalDetail: true,
+ });
+ const promptValue = promptRollback?.value as Record | undefined;
+
+ expect(promptValue).toMatchObject({
+ prompt: 'My prompt',
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ });
+ expect(promptValue?.errors).toBeTruthy();
+ });
+});
diff --git a/tests/unit/pages/settings/AddAgentPageTest.tsx b/tests/unit/pages/settings/AddAgentPageTest.tsx
new file mode 100644
index 000000000000..f54bb1b37a61
--- /dev/null
+++ b/tests/unit/pages/settings/AddAgentPageTest.tsx
@@ -0,0 +1,135 @@
+import {render} from '@testing-library/react-native';
+import React from 'react';
+import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
+import AddAgentPage from '@pages/settings/Agents/AddAgentPage';
+
+jest.mock('@userActions/Agent', () => ({
+ createAgent: jest.fn(),
+}));
+
+const mockTranslate = jest.fn().mockImplementation((key: string, param?: string) => (param !== undefined ? `${key}(${param})` : key));
+
+jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: mockTranslate})));
+
+jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({})));
+
+jest.mock('@hooks/useTheme', () => jest.fn(() => ({textLight: '#fff'})));
+
+jest.mock('@hooks/useThemeStyles', () =>
+ jest.fn(
+ () =>
+ new Proxy(
+ {},
+ {
+ get: () => ({}),
+ },
+ ),
+ ),
+);
+
+jest.mock('@hooks/useLazyAsset', () => ({
+ useMemoizedLazyIllustrations: jest.fn(() => ({AiBot: 1})),
+ useMemoizedLazyExpensifyIcons: jest.fn(() => ({Sync: 1})),
+}));
+
+jest.mock('@libs/Navigation/Navigation', () => ({
+ goBack: jest.fn(),
+}));
+
+jest.mock('@react-navigation/native', () => {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const actual = jest.requireActual('@react-navigation/native');
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
+ return {
+ ...actual,
+ useIsFocused: () => true,
+ useRoute: jest.fn(() => ({name: '', key: '', params: {}})),
+ };
+});
+
+jest.mock('@components/ScreenWrapper', () => {
+ function MockScreenWrapper({children}: {children: React.ReactNode}) {
+ return children;
+ }
+ return MockScreenWrapper;
+});
+
+jest.mock('@components/HeaderWithBackButton', () => {
+ function MockHeader({title}: {title: string}) {
+ return title;
+ }
+ return MockHeader;
+});
+
+jest.mock('@components/Form/FormProvider', () => {
+ function MockFormProvider({children}: {children: React.ReactNode}) {
+ return children;
+ }
+ return MockFormProvider;
+});
+
+jest.mock('@components/Form/InputWrapper', () => {
+ function MockInputWrapper({inputID, defaultValue}: {inputID: string; defaultValue?: string}) {
+ return `${inputID}::${defaultValue ?? ''}`;
+ }
+ return MockInputWrapper;
+});
+
+jest.mock('@components/AvatarButtonWithIcon', () => {
+ function MockAvatarButtonWithIcon() {
+ return null;
+ }
+ return MockAvatarButtonWithIcon;
+});
+
+const mockUseCurrentUserPersonalDetails = jest.mocked(useCurrentUserPersonalDetails);
+
+describe('AddAgentPage', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseCurrentUserPersonalDetails.mockReturnValue({accountID: 0});
+ });
+
+ it('renders page title', () => {
+ const {toJSON} = render();
+
+ expect(JSON.stringify(toJSON())).toContain('addAgentPage.title');
+ });
+
+ it('translates default agent name using current user displayName', () => {
+ mockUseCurrentUserPersonalDetails.mockReturnValue({accountID: 0, displayName: 'Nicolas'});
+
+ render();
+
+ expect(mockTranslate).toHaveBeenCalledWith('addAgentPage.defaultAgentName', 'Nicolas');
+ });
+
+ it('sets default agent name as InputWrapper defaultValue when displayName exists', () => {
+ mockUseCurrentUserPersonalDetails.mockReturnValue({accountID: 0, displayName: 'Nicolas'});
+
+ const {toJSON} = render();
+
+ expect(JSON.stringify(toJSON())).toContain('firstName::addAgentPage.defaultAgentName(Nicolas)');
+ });
+
+ it('sets no default agent name when displayName is absent', () => {
+ mockUseCurrentUserPersonalDetails.mockReturnValue({accountID: 0});
+
+ const {toJSON} = render();
+
+ expect(JSON.stringify(toJSON())).toContain('firstName::');
+ expect(mockTranslate).not.toHaveBeenCalledWith('addAgentPage.defaultAgentName', expect.anything());
+ });
+
+ it('always sets default prompt regardless of displayName', () => {
+ render();
+
+ expect(mockTranslate).toHaveBeenCalledWith('addAgentPage.defaultPrompt');
+ });
+
+ it('sets default prompt as InputWrapper defaultValue', () => {
+ const {toJSON} = render();
+
+ expect(JSON.stringify(toJSON())).toContain('prompt::addAgentPage.defaultPrompt');
+ });
+});