Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5898,6 +5898,7 @@ const CONST = {
EXPENSE_DEFAULTS: 'expenseDefaults',
REQUIRE_FIELDS: 'requireFields',
FLAG_FOR_REVIEW: 'flagForReview',
AGENTS: 'agents',
},
SPLIT: {
AMOUNT: 'amount',
Expand Down Expand Up @@ -8485,6 +8486,7 @@ const CONST = {
NEW_RULE_MENU_ITEM_FLAG_FOR_REVIEW: 'WorkspaceRules-NewRuleMenuItem-FlagForReview',
NEW_RULE_MENU_ITEM_REQUIRE_FIELDS: 'WorkspaceRules-NewRuleMenuItem-RequireFields',
NEW_RULE_MENU_ITEM_APPLY_EXPENSE_DEFAULTS: 'WorkspaceRules-NewRuleMenuItem-ApplyExpenseDefaults',
NEW_RULE_MENU_ITEM_CREATE_AGENT_RULE: 'WorkspaceRules-NewRuleMenuItem-CreateAgentRule',
REQUIRE_RECEIPTS_SAVE: 'WorkspaceRules-RequireReceiptsSave',
REQUIRE_FIELDS_SAVE: 'WorkspaceRules-RequireFieldsSave',
FLAG_RECEIPT_LINE_ITEMS_SAVE: 'WorkspaceRules-FlagReceiptLineItemsSave',
Expand Down
17 changes: 15 additions & 2 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7417,6 +7417,7 @@ const translations = {
expenseDefaults: 'Expense defaults',
requireFields: 'Require fields',
flagForReview: 'Flag for review',
agents: 'Agents',
},
bulkActions: {
deleteMultiple: () => ({
Expand Down Expand Up @@ -7593,11 +7594,13 @@ const translations = {
restrictCardSpend: 'Restrict card spend',
restrictCardSpendDescription: 'Block or limit spend at the point of sale',
flagForReview: 'Flag for review',
flagForReviewDescription: 'Notify approvers when expenses exceed category limits',
flagForReviewDescription: 'Notify when your conditions are met.',
requireFields: 'Require fields',
requireFieldsDescription: 'Make sure key fields are filled in before expenses are submitted',
requireFieldsDescription: 'Receipts, categories, etc, when submitting.',
applyExpenseDefaults: 'Apply expense defaults',
applyExpenseDefaultsDescription: 'Update fields without submitter doing anything',
createAgentRule: 'Agent rule',
createAgentRuleDescription: 'Describe flexible rules that run when you need.',
},
expenseDefaultsTable: {
tableColumnType: 'Type',
Expand Down Expand Up @@ -7657,6 +7660,11 @@ const translations = {
subtitle: 'Alert approvers when specific expenses are worth an extra review.',
cta: 'Create flag rule',
},
agentRulesEmptyState: {
title: 'No agent rules added',
subtitle: 'Create a rule to automate your workspace policies.',
cta: 'Add AI rule',
},
flagForReviewRule: {
title: 'Flag for review',
subtitle: 'Notify approvers when the following conditions are met.',
Expand Down Expand Up @@ -7845,19 +7853,24 @@ const translations = {
agentRules: {
title: 'Agent rules',
subtitle: 'Set rules for how AI agents handle expenses on this workspace.',
revampSubtitle: 'Describe flexible rules that run when you need.',
enforcedBy: 'Agent rules are enforced by',
ruleBotName: 'RuleBot',
addRule: 'Add agent rule',
findRule: 'Find agent rule',
addRuleTitle: 'Add rule',
newRuleTitle: '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',
describeRuleForConcierge: 'Describe your rule and Concierge will build it',
disclaimer: 'AI agents can make mistakes.',
nextButton: 'Next',
agentCreatedTitle: 'RuleBot has been added to your workspace!',
agentCreatedDescription: (agentsRoute: string) =>
`<muted-text>To enforce your agent rules, we’ve created an agent for you and added it as an admin to your workspace.<br><br>Edit your agent’s details in <a href="${agentsRoute}">Account &gt; Agents</a>.</muted-text>`,
gotIt: 'Got it',
},
},
planTypePage: {
Expand Down
31 changes: 31 additions & 0 deletions src/libs/AgentRulesUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import CONST from '@src/CONST';
import type {AgentRule} from '@src/types/onyx/Policy';

type AgentRulesCollection = Record<string, AgentRule> | undefined;

type AgentRuleWithID = AgentRule & {
ruleID: string;
};

function getAgentRuleDisplayTitle(rule: AgentRule): string {
return (rule.title ?? rule.prompt).replaceAll(/\s+/g, ' ').trim();
}

function getSortedAgentRules(agentRules: AgentRulesCollection): AgentRuleWithID[] {
return Object.entries(agentRules ?? {})
.filter(([, rule]) => !!rule)
.map(([ruleID, rule]) => ({...rule, ruleID}))
.sort((a, b) => {
if (a.created && b.created) {
return a.created < b.created ? 1 : -1;
}

return 0;
});
}

function getVisibleAgentRules(agentRules: AgentRulesCollection, isOffline: boolean): AgentRuleWithID[] {
return getSortedAgentRules(agentRules).filter((rule) => isOffline || rule.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
}

export {getAgentRuleDisplayTitle, getVisibleAgentRules};
52 changes: 40 additions & 12 deletions src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import usePermissions from '@hooks/usePermissions';
import usePolicy from '@hooks/usePolicy';
import useThemeStyles from '@hooks/useThemeStyles';

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';
Expand All @@ -33,7 +34,7 @@ import type SCREENS from '@src/SCREENS';
import INPUT_IDS from '@src/types/form/AddAgentRuleForm';
import {isEmptyObject} from '@src/types/utils/EmptyObject';

import type {TextInputKeyPressEvent} from 'react-native';
import type {StyleProp, TextInputKeyPressEvent, ViewStyle} from 'react-native';

import React, {useRef} from 'react';
import {View} from 'react-native';
Expand All @@ -51,6 +52,8 @@ function AddAgentRulePage({
const shouldUseScrollableLayout = useIsInLandscapeMode();
const {isBetaEnabled} = usePermissions();
const isCustomAgentEnabled = isBetaEnabled(CONST.BETAS.CUSTOM_AGENT);
const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP);
const shouldUseExpandedRevampFormLayout = isRulesRevampEnabled && !shouldUseScrollableLayout;
const policy = usePolicy(policyID);
const formRef = useRef<FormRef>(null);
const linkPressedRef = useRef(false);
Expand All @@ -73,13 +76,23 @@ function AddAgentRulePage({
return errors;
};

const navigateBackToAgentsTab = () => {
if (isRulesRevampEnabled) {
Tab.setSelectedTab(CONST.TAB.RULES_TAB_TYPE, CONST.TAB.RULES.AGENTS);
Navigation.goBack(ROUTES.WORKSPACE_RULES.getRoute(policyID));
return;
}

Navigation.goBack();
};

const saveRule = (values: FormOnyxValues<AddAgentRuleFormID>): 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);
addPolicyAgentRule(policyID, rand64(), values[INPUT_IDS.PROMPT]);
if (!isFirstRule) {
Navigation.goBack();
navigateBackToAgentsTab();
return;
}
linkPressedRef.current = false;
Expand All @@ -100,25 +113,37 @@ function AddAgentRulePage({
/>
</View>
),
confirmText: translate('common.buttonConfirm'),
confirmText: isRulesRevampEnabled ? translate('workspace.rules.agentRules.gotIt') : translate('common.buttonConfirm'),
shouldShowCancelButton: false,
shouldUseSuccessStyleForConfirm: true,
iconSource: BotAvatarBlue,
iconFill: false,
shouldCenterIcon: true,
iconWidth: variables.iconSizeUltraLarge,
iconHeight: variables.iconSizeUltraLarge,
iconAdditionalStyles: {borderRadius: variables.iconSizeUltraLarge / 2, overflow: 'hidden', marginTop: 12},
iconAdditionalStyles: {
borderRadius: variables.iconSizeUltraLarge / 2,
overflow: 'hidden',
marginTop: 12,
},
}).then(() => {
if (!linkPressedRef.current) {
if (linkPressedRef.current) {
Navigation.navigate(ROUTES.SETTINGS_AGENTS);
return;
}
Navigation.navigate(ROUTES.SETTINGS_AGENTS);

if (isRulesRevampEnabled) {
Tab.setSelectedTab(CONST.TAB.RULES_TAB_TYPE, CONST.TAB.RULES.AGENTS);
}
});
},
});
};

const inputWrapperStyles: StyleProp<ViewStyle> = shouldUseExpandedRevampFormLayout
? [styles.flex1, styles.mnh0, styles.agentRulePromptInput]
: [styles.flex1, shouldUseScrollableLayout && styles.minHeight42];

return (
<AccessOrNotFoundWrapper
policyID={policyID}
Expand All @@ -130,31 +155,34 @@ function AddAgentRulePage({
testID="AddAgentRulePage"
offlineIndicatorStyle={styles.mtAuto}
includeSafeAreaPaddingBottom
shouldEnableMaxHeight={shouldUseScrollableLayout}
shouldEnableMaxHeight={shouldUseScrollableLayout || shouldUseExpandedRevampFormLayout}
>
<HeaderWithBackButton title={translate('workspace.rules.agentRules.addRuleTitle')} />
<HeaderWithBackButton title={isRulesRevampEnabled ? translate('workspace.rules.agentRules.newRuleTitle') : translate('workspace.rules.agentRules.addRuleTitle')} />
<FormProvider
ref={formRef}
formID={ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM}
validate={validate}
onSubmit={saveRule}
submitButtonText={translate('common.save')}
submitButtonText={isRulesRevampEnabled ? translate('workspace.rules.agentRules.nextButton') : translate('common.save')}
style={[styles.flex1, styles.ph5]}
shouldUseScrollView={shouldUseScrollableLayout}
submitFlexEnabled={shouldUseScrollableLayout ? undefined : false}
shouldSubmitButtonStickToBottom={shouldUseExpandedRevampFormLayout}
enabledWhenOffline
shouldHideFixErrorsAlert
shouldValidateOnChange
shouldValidateOnBlur
keyboardSubmitBehavior={CONST.KEYBOARD_SUBMIT_BEHAVIOR.SUBMIT_ONLY}
>
<View style={styles.flex1}>
<View style={[styles.flex1, shouldUseScrollableLayout && styles.minHeight42]}>
<View style={inputWrapperStyles}>
<InputWrapper
InputComponent={TextInput}
inputID={INPUT_IDS.PROMPT}
label={translate('workspace.rules.agentRules.describeRuleTitle')}
accessibilityLabel={translate('workspace.rules.agentRules.describeRuleTitle')}
label={isRulesRevampEnabled ? translate('workspace.rules.agentRules.describeRuleForConcierge') : translate('workspace.rules.agentRules.describeRuleTitle')}
accessibilityLabel={
isRulesRevampEnabled ? translate('workspace.rules.agentRules.describeRuleForConcierge') : translate('workspace.rules.agentRules.describeRuleTitle')
}
role={CONST.ROLE.PRESENTATION}
onKeyPress={handleKeyPress}
multiline
Expand Down
18 changes: 13 additions & 5 deletions src/pages/workspace/rules/AgentRules/EditAgentRulePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
import INPUT_IDS from '@src/types/form/EditAgentRuleForm';

import type {TextInputKeyPressEvent} from 'react-native';
import type {StyleProp, TextInputKeyPressEvent, ViewStyle} from 'react-native';

import React, {useRef} from 'react';
import {View} from 'react-native';
Expand All @@ -48,9 +48,12 @@ function EditAgentRulePage({
const {showConfirmModal} = useConfirmModal();
const {isBetaEnabled} = usePermissions();
const isCustomAgentEnabled = isBetaEnabled(CONST.BETAS.CUSTOM_AGENT);
const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP);
const shouldUseExpandedRevampFormLayout = isRulesRevampEnabled && !shouldUseScrollableLayout;
const policy = usePolicy(policyID);
const agentRule = policy?.rules?.agentRules?.[ruleID];
const formRef = useRef<FormRef>(null);
const describeRuleLabel = isRulesRevampEnabled ? translate('workspace.rules.agentRules.describeRuleForConcierge') : translate('workspace.rules.agentRules.describeRuleTitle');

const handleKeyPress = (e: TextInputKeyPressEvent | KeyboardEvent) => {
if (!('key' in e)) {
Expand Down Expand Up @@ -103,6 +106,10 @@ function EditAgentRulePage({
return <NotFoundPage />;
}

const inputWrapperStyles: StyleProp<ViewStyle> = shouldUseExpandedRevampFormLayout
? [styles.flex1, styles.mnh0, styles.agentRulePromptInput]
: [styles.flex1, shouldUseScrollableLayout && styles.minHeight42];

return (
<AccessOrNotFoundWrapper
policyID={policyID}
Expand All @@ -114,7 +121,7 @@ function EditAgentRulePage({
testID="EditAgentRulePage"
offlineIndicatorStyle={styles.mtAuto}
includeSafeAreaPaddingBottom
shouldEnableMaxHeight={shouldUseScrollableLayout}
shouldEnableMaxHeight={shouldUseScrollableLayout || shouldUseExpandedRevampFormLayout}
>
<HeaderWithBackButton title={translate('workspace.rules.agentRules.editRuleTitle')} />
<FormProvider
Expand All @@ -126,6 +133,7 @@ function EditAgentRulePage({
style={[styles.flex1, styles.ph5]}
shouldUseScrollView={shouldUseScrollableLayout}
submitFlexEnabled={shouldUseScrollableLayout ? undefined : false}
shouldSubmitButtonStickToBottom={shouldUseExpandedRevampFormLayout}
enabledWhenOffline
shouldHideFixErrorsAlert
shouldValidateOnChange
Expand All @@ -143,12 +151,12 @@ function EditAgentRulePage({
}
>
<View style={styles.flex1}>
<View style={[styles.flex1, shouldUseScrollableLayout && styles.minHeight42]}>
<View style={inputWrapperStyles}>
<InputWrapper
InputComponent={TextInput}
inputID={INPUT_IDS.PROMPT}
label={translate('workspace.rules.agentRules.describeRuleTitle')}
accessibilityLabel={translate('workspace.rules.agentRules.describeRuleTitle')}
label={describeRuleLabel}
accessibilityLabel={describeRuleLabel}
role={CONST.ROLE.PRESENTATION}
onKeyPress={handleKeyPress}
defaultValue={agentRule.prompt}
Expand Down
21 changes: 4 additions & 17 deletions src/pages/workspace/rules/AgentRulesSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ import usePolicy from '@hooks/usePolicy';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';

import {getAgentRuleDisplayTitle, getVisibleAgentRules} from '@libs/AgentRulesUtils';
import Navigation from '@libs/Navigation/Navigation';
import {isPolicyMemberWithoutPendingDelete} from '@libs/PolicyUtils';

import {clearPolicyAgentRuleErrors} from '@userActions/Policy/Rules';

import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import {isEmptyObject} from '@src/types/utils/EmptyObject';

import React from 'react';
import {View} from 'react-native';
Expand All @@ -41,7 +41,8 @@ function AgentRulesSection({policyID, canWriteRules, showReadOnlyModal}: AgentRu
const personalDetailsList = usePersonalDetails();
const expensifyIcons = useMemoizedLazyExpensifyIcons(['Plus']);
const agentRules = policy?.rules?.agentRules;
const hasRules = !isEmptyObject(agentRules);
const visibleRules = getVisibleAgentRules(agentRules, isOffline);
const hasRules = visibleRules.length > 0;

// RuleBot is the agent the backend provisions on the first Agent rule and stores on the policy.
const ruleBotAccountID = policy?.ruleBotAccountID;
Expand All @@ -51,20 +52,6 @@ function AgentRulesSection({policyID, canWriteRules, showReadOnlyModal}: AgentRu
// ruleBotAccountID stays set on the policy after RuleBot is removed from the workspace, so also require it to still be an active member before showing the "enforced by" line.
const isRuleBotActiveMember = isPolicyMemberWithoutPendingDelete(ruleBot?.login, policy);

const sortedRules = Object.entries(agentRules ?? {})
.filter(([, rule]) => !!rule)
.map(([ruleID, rule]) => ({...rule, ruleID}))
.sort((a, b) => {
if (a.created && b.created) {
return a.created < b.created ? 1 : -1;
}
return 0;
});

// Exclude pending-delete rules when online because OfflineWithFeedback hides them visually.
// When offline, keep them so OfflineWithFeedback can show strikethrough styling.
const visibleRules = sortedRules.filter((rule) => isOffline || rule.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);

const renderTitle = () => (
<View style={[styles.flexRow, styles.alignItemsCenter]}>
<Text style={[styles.textHeadline, styles.cardSectionTitle, styles.accountSettingsSectionTitle, {color: theme.text}]}>{translate('workspace.rules.agentRules.title')}</Text>
Expand Down Expand Up @@ -112,7 +99,7 @@ function AgentRulesSection({policyID, canWriteRules, showReadOnlyModal}: AgentRu
onClose={() => clearPolicyAgentRuleErrors(policyID, rule.ruleID, rule)}
>
<MenuItemWithTopDescription
title={(rule.title ?? rule.prompt).replaceAll(/\s+/g, ' ').trim()}
title={getAgentRuleDisplayTitle(rule)}
numberOfLinesTitle={1}
wrapperStyle={[styles.borderedContentCard, styles.ph4, styles.pv2]}
shouldShowRightIcon
Expand Down
Loading
Loading