Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
1c1378f
add add agent button
s77rt Oct 13, 2026
04bc2f9
add role row for agent
s77rt Oct 13, 2026
aef8001
prepare for company-agent creation
s77rt Oct 13, 2026
57b0b89
add CreateCompanyAgent api params
s77rt Oct 13, 2026
ecad0f3
make ownerAccountID and ownerType optional as they cannot be predicte…
s77rt Oct 13, 2026
f8e5ab7
define prepareNewAgentOnyxData to extract common agent creation logic
s77rt Oct 13, 2026
757af65
add createCompanyAgent
s77rt Oct 13, 2026
2c460a5
call createCompanyAgent
s77rt Oct 13, 2026
468b669
Merge branch 'company-agent-split-agents-sections' into company-agent…
s77rt Oct 14, 2026
f076477
fix conflict
s77rt Oct 19, 2026
1dc4f7b
fix AddAgentPageTest
s77rt Oct 19, 2026
adc4142
fix react compiler warnings
s77rt Oct 19, 2026
41228ff
ts
s77rt Oct 19, 2026
a06455f
lint
s77rt Oct 19, 2026
a4df129
add comment
s77rt Oct 19, 2026
81c9619
pass policy object to createCompanyAgent and remove policyID from cre…
s77rt Oct 19, 2026
13d80dd
fix optimistic data for agent owner
s77rt Oct 19, 2026
5e37f69
fix AddAgentPageTest
s77rt Oct 19, 2026
cf1216e
fix AgentActionTest
s77rt Oct 19, 2026
ce2b810
Merge branch 'main' into company-agent-create
s77rt Oct 20, 2026
d21c357
fix crash
s77rt Oct 20, 2026
eff60be
better type safety
s77rt Oct 20, 2026
0823caf
handle default role properely
s77rt Oct 20, 2026
5f364fc
clean up
s77rt Oct 20, 2026
2147496
better types
s77rt Oct 20, 2026
51b9673
remov stale comment
s77rt Oct 20, 2026
4cb63cb
fix test
s77rt Oct 20, 2026
51205aa
lint
s77rt Oct 20, 2026
35c6e6f
prevent deeplink access for users who can not add agents
s77rt Oct 20, 2026
7477ca6
hide Agents option from agents
s77rt Oct 20, 2026
8894017
ts
s77rt Oct 20, 2026
bd073da
fix AddAgentPageTest
s77rt Oct 20, 2026
bb85352
fix NewAgentPageTest
s77rt Oct 20, 2026
43ba2f5
lint
s77rt Oct 20, 2026
50f46cb
restrict WorkspaceAgentsPage from agents
s77rt Oct 20, 2026
f02a68a
fix navigation bug for agent
s77rt Oct 20, 2026
37eae1f
fix navigation timing
s77rt Oct 20, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {isAdminSelector} from '@src/selectors/Domain';
import {lastExpensesSearchQuerySelector} from '@src/selectors/SearchFilters';
import {emailSelector} from '@src/selectors/Session';
import type * as OnyxTypes from '@src/types/onyx';
import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails';
import type IconAsset from '@src/types/utils/IconAsset';

import type {ReactNode} from 'react';
Expand Down Expand Up @@ -134,6 +135,9 @@ type BuildWorkspaceNavigationItemsParams = {
/** Login used to evaluate Workspace membership and permissions. */
currentUserLogin: string | undefined;

/** Personal details used to evaluate Workspace membership and permissions. */
currentUserPersonalDetails: CurrentUserPersonalDetails;

/** Icons consumed by the shared Workspace menu source. */
icons: Parameters<typeof getWorkspaceMenuItems>[0]['icons'];

Expand Down Expand Up @@ -295,6 +299,7 @@ function buildWorkspaceNavigationItems({
policies,
policyCategories,
currentUserLogin,
currentUserPersonalDetails,
icons,
isOffline,
isVendorMatchingBetaEnabled,
Expand All @@ -316,6 +321,7 @@ function buildWorkspaceNavigationItems({
policy,
policyID: policy.id,
currentUserLogin,
currentUserPersonalDetails,
icons,
policyCategories: policyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy.id}`],
isVendorMatchingBetaEnabled,
Expand Down Expand Up @@ -454,6 +460,7 @@ function useNavigationSuggestions(query: string, shouldWatchForApprovals = true)
policies: allPolicies,
policyCategories,
currentUserLogin,
currentUserPersonalDetails,
icons,
isOffline: !!isOffline,
isVendorMatchingBetaEnabled: isBetaEnabled(CONST.BETAS.VENDOR_MATCHING),
Expand Down
6 changes: 3 additions & 3 deletions src/hooks/useAgents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,14 @@ function useAgents() {
let ownerDisplayName: string | undefined;
let ownerPolicyID: string | undefined;
if (agent.ownerType === CONST.AGENTS.OWNER_TYPE.PERSON) {
const ownerDetails = personalDetailsList?.[agent.ownerAccountID];
const ownerDetails = agent.ownerAccountID ? personalDetailsList?.[agent.ownerAccountID] : undefined;
ownerDisplayName = ownerDetails?.displayName ?? ownerDetails?.login;
} else if (agent.ownerType === CONST.AGENTS.OWNER_TYPE.WORKSPACE) {
const policy = policiesByAccountID.get(agent.ownerAccountID);
const policy = agent.ownerAccountID ? policiesByAccountID.get(agent.ownerAccountID) : undefined;
ownerDisplayName = policy?.name;
ownerPolicyID = policy?.id;
} else if (agent.ownerType === CONST.AGENTS.OWNER_TYPE.DOMAIN) {
const ownerDetails = personalDetailsList?.[agent.ownerAccountID];
const ownerDetails = agent.ownerAccountID ? personalDetailsList?.[agent.ownerAccountID] : undefined;
ownerDisplayName = ownerDetails?.login ? Str.extractEmailDomain(ownerDetails.login) : undefined;
}

Expand Down
3 changes: 3 additions & 0 deletions src/hooks/useWorkspaceMembers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import useNetwork from './useNetwork';
import {usePersonalDetailsByLogins} from './usePersonalDetailByLogin';
import {useAllPersonalDetails} from './usePersonalDetails';

/**
* Hook that returns a workspace's members joined with personal details, filling in fallback identities when details haven't loaded and filtering out Expensify team members on Expensify-owned policies.
*/
function useWorkspaceMembers(policy: OnyxEntry<Policy>) {
const policyID = policy?.id;
const policyOwner = policy?.owner;
Expand Down
19 changes: 19 additions & 0 deletions src/libs/API/parameters/CreateCompanyAgentParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types';

import type CONST from '@src/CONST';

import type {ValueOf} from 'type-fest';

type CreateCompanyAgentParams = {
prompt: string;
policyID: string;
role: ValueOf<typeof CONST.POLICY.ROLE>;
firstName?: string;
customExpensifyAvatarID?: string;
file?: File | CustomRNImageManipulatorResult;
optimisticAccountID: number;
Comment thread
s77rt marked this conversation as resolved.
Resolved
optimisticReportID: string;
createdReportActionID: string;
};

export default CreateCompanyAgentParams;
1 change: 1 addition & 0 deletions src/libs/API/parameters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,7 @@ export type {default as UpdateDomainSecurityGroupParams} from './UpdateDomainSec
export type {default as SetDefaultDomainSecurityGroupParams} from './SetDefaultDomainSecurityGroupParams';
export type {default as DeleteDomainSecurityGroupParams} from './DeleteDomainSecurityGroupParams';
export type {default as CreateAgentParams} from './CreateAgentParams';
export type {default as CreateCompanyAgentParams} from './CreateCompanyAgentParams';
export type {default as CreateDomainSecurityGroupParams} from './CreateDomainSecurityGroupParams';
export type {default as UpdateAgentNameParams} from './UpdateAgentNameParams';
export type {default as UpdateAgentPromptParams} from './UpdateAgentPromptParams';
Expand Down
2 changes: 2 additions & 0 deletions src/libs/API/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,7 @@ const WRITE_COMMANDS = {
SET_DEFAULT_DOMAIN_SECURITY_GROUP: 'SetDefaultDomainSecurityGroup',
DELETE_DOMAIN_SECURITY_GROUP: 'DeleteDomainSecurityGroup',
CREATE_AGENT: 'CreateAgent',
CREATE_COMPANY_AGENT: 'CreateCompanyAgent',
CREATE_DOMAIN_SECURITY_GROUP: 'CreateDomainSecurityGroup',
UPDATE_AGENT_NAME: 'UpdateAgentName',
UPDATE_AGENT_PROMPT: 'UpdateAgentPrompt',
Expand Down Expand Up @@ -1610,6 +1611,7 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.SET_DEFAULT_DOMAIN_SECURITY_GROUP]: Parameters.SetDefaultDomainSecurityGroupParams;
[WRITE_COMMANDS.DELETE_DOMAIN_SECURITY_GROUP]: Parameters.DeleteDomainSecurityGroupParams;
[WRITE_COMMANDS.CREATE_AGENT]: Parameters.CreateAgentParams;
[WRITE_COMMANDS.CREATE_COMPANY_AGENT]: Parameters.CreateCompanyAgentParams;
[WRITE_COMMANDS.UPDATE_AGENT_NAME]: Parameters.UpdateAgentNameParams;
[WRITE_COMMANDS.UPDATE_AGENT_PROMPT]: Parameters.UpdateAgentPromptParams;
[WRITE_COMMANDS.UPDATE_AGENT_AVATAR]: Parameters.UpdateAgentAvatarParams;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import useThemeStyles from '@hooks/useThemeStyles';

import createSplitNavigator from '@libs/Navigation/AppNavigator/createSplitNavigator';
import useSplitNavigatorScreenOptions from '@libs/Navigation/AppNavigator/useSplitNavigatorScreenOptions';
import withAgentAccessDenied from '@libs/Navigation/AppNavigator/withAgentAccessDenied';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import type {WorkspaceNavigatorParamList, WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types';

Expand All @@ -23,7 +24,7 @@ const CENTRAL_PANE_WORKSPACE_SCREENS = {
[SCREENS.WORKSPACE.WORKFLOWS]: () => require<ReactComponentModule>('../../../../pages/workspace/workflows/WorkspaceWorkflowsPageRevamp').default,
[SCREENS.WORKSPACE.INVOICES]: () => require<ReactComponentModule>('../../../../pages/workspace/invoices/WorkspaceInvoicesPage').default,
[SCREENS.WORKSPACE.MEMBERS]: () => require<ReactComponentModule>('../../../../pages/workspace/WorkspaceMembersPage').default,
[SCREENS.WORKSPACE.AGENTS]: () => require<ReactComponentModule>('../../../../pages/workspace/WorkspaceAgentsPage').default,
[SCREENS.WORKSPACE.AGENTS]: withAgentAccessDenied(() => require<ReactComponentModule>('../../../../pages/workspace/WorkspaceAgentsPage').default),
[SCREENS.WORKSPACE.ROOMS]: () => require<ReactComponentModule>('../../../../pages/workspace/rooms/WorkspaceRoomsPage').default,
[SCREENS.WORKSPACE.ACCOUNTING.ROOT]: () => require<ReactComponentModule>('../../../../pages/workspace/accounting/PolicyAccountingPage').default,
[SCREENS.WORKSPACE.HR]: () => require<ReactComponentModule>('../../../../pages/workspace/hr/WorkspaceHRPage').default,
Expand Down
15 changes: 15 additions & 0 deletions src/libs/Navigation/AppNavigator/withAgentAccessDenied.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView
import useIsAgentAccount from '@hooks/useIsAgentAccount';
import useOnyx from '@hooks/useOnyx';

import getTopmostFullScreenRoute from '@libs/Navigation/helpers/getTopmostFullScreenRoute';
import Navigation from '@libs/Navigation/Navigation';

import NAVIGATORS from '@src/NAVIGATORS';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';

Expand Down Expand Up @@ -46,6 +48,13 @@ function withAgentAccessDenied(getComponent: () => React.ComponentType): () => R
forceReplace: true,
});
};
const resetWorkspaceTabAndNavigateToHome = () => {
Navigation.popRootToTop();
Navigation.navigate(ROUTES.WORKSPACES_LIST.getRoute(), {
forceReplace: true,
afterTransition: () => Navigation.navigate(ROUTES.HOME),
});
};

// The guarded screen can be open inside a modal/RHP (e.g. the agent-edit page the owner was
// on when they tapped "Copilot into account"), or an unguarded RHP (e.g. the agent DM) can be
Expand All @@ -59,6 +68,12 @@ function withAgentAccessDenied(getComponent: () => React.ComponentType): () => R
return;
}

const topmostFullScreenRoute = getTopmostFullScreenRoute();
if (topmostFullScreenRoute?.name === NAVIGATORS.WORKSPACE_NAVIGATOR || topmostFullScreenRoute?.name === NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR) {
resetWorkspaceTabAndNavigateToHome();
return;
}

redirectToProfile();
});
}, [isAgent]);
Expand Down
88 changes: 72 additions & 16 deletions src/libs/actions/Agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type PolicyEmployee from '@src/types/onyx/PolicyEmployee';
import type {AnyOnyxUpdate} from '@src/types/onyx/Request';

import type {OnyxCollection, OnyxCollectionInputValue, OnyxUpdate} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';

import Onyx from 'react-native-onyx';

Expand All @@ -40,15 +41,14 @@ function openProfilePage() {
read(READ_COMMANDS.OPEN_PROFILE_PAGE, null);
}

function createAgent(
firstName: string | undefined,
function prepareNewAgentOnyxData(
currentUserAccountID: number,
currentUserLogin: string | undefined,
prompt: string,
ownerAccountID: number,
ownerLogin: string | undefined,
policy?: Policy,
firstName?: string,
customExpensifyAvatarID?: string,
file?: File | CustomRNImageManipulatorResult,
optimisticAvatarURI?: string,
policyID?: string,
) {
const optimisticAccountID = Number(generateReportID());

Expand All @@ -59,7 +59,7 @@ function createAgent(
avatarURI = optimisticAvatarURI;
}

// Optimistic row for Settings > Agents until CREATE_AGENT responds. Omit `login` (server-assigned).
// Optimistic row for Settings > Agents until BE responds. Omit `login` (server-assigned).
const optimisticPersonalDetail = {
accountID: optimisticAccountID,
displayName: firstName,
Expand All @@ -71,24 +71,29 @@ function createAgent(
// Generate the DM report ID client-side so it can be written to Onyx and opened immediately.
const optimisticReportID = generateReportID();
const optimisticChatReport = buildOptimisticChatReport({
participantList: [ownerAccountID, optimisticAccountID],
ownerAccountID,
participantList: [currentUserAccountID, optimisticAccountID],
ownerAccountID: currentUserAccountID,
optimisticReportID,
currentUserAccountID: ownerAccountID,
currentUserAccountID,
});

// Reuse the client-generated CREATED action ID when CreateAgent creates the DM.
const createdReportActionID = rand64();
const optimisticCreatedAction = buildOptimisticCreatedReportAction({
emailCreatingAction: ownerLogin ?? CONST.REPORT.OWNER_EMAIL_FAKE,
currentUserAccountID: ownerAccountID,
emailCreatingAction: currentUserLogin ?? CONST.REPORT.OWNER_EMAIL_FAKE,
currentUserAccountID,
optimisticReportActionID: createdReportActionID,
});

const optimisticData: AnyOnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.AGENT}${optimisticAccountID}`,
value: {prompt, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD},
value: {
prompt,
ownerAccountID: policy?.policyAccountID,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The optimistic data for company agents looks incomplete, so the offline state doesn't match what the server returns. useWorkspaceAgents keeps an agent only if agent.ownerAccountID === workspaceAccountID (policy.policyAccountID ?? 0) or if the agent is in policy.employeeList. Right now we set ownerAccountID but not ownerType, and we don't add an employeeList entry.

I reproduced two problems on web:

1. Role and Owner columns are empty offline

  1. Go offline.
  2. Workspace > Agents > New agent > Build custom agent, pick role Card admin, then Create agent.
  3. Workspace > Agents: the agent shows as pending, but the Role column is empty.
  4. Settings > Agents: the Owner column is empty.

Once back online, the role and owner both fill in correctly.

2. The agent is missing entirely in a workspace created offline

  1. Go offline.
  2. Workspaces > New, then create a workspace. It has no policyAccountID yet, because only the server sets it.
  3. In that workspace: Agents > New agent > Build custom agent, then Create agent.
  4. Workspace > Agents shows "No agents created". The optimistic agent has ownerAccountID: undefined, undefined !== 0, and it isn't in employeeList yet, so it's filtered out.

Once back online, CreateWorkspace → CreateCompanyAgent both succeed and the agent appears.

For reference, the server response for CreateCompanyAgent includes ownerType: "workspace" and an employeeList entry with the chosen role. Could we mirror that optimistically in createCompanyAgent, with a failure rollback?

// agent
value: {
    prompt,
    ownerAccountID: policy?.policyAccountID,
    ownerType: policy ? CONST.AGENTS.OWNER_TYPE.WORKSPACE : CONST.AGENTS.OWNER_TYPE.PERSON,
    pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
},

// policy: employeeList entry with `role` + `pendingAction: ADD`

The employeeList entry would fix both problems: the Role column fills in, and the row no longer depends on policyAccountID. The tricky part is that the agent's login (<name>_<accountID>@expensify.ai) is server-assigned, so the entry needs a key we can match up when the server responds. Alternatively, useWorkspaceAgents could also keep pending-add agents created for this policyID.

The PR's offline steps are "Same as Tests", and step 4 ("Verify the agent is added to the workspace with the chosen role") can't pass offline as it stands.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The agent email is not predictable 100% thus the missing employee entry and with that entry missing I had to put ownerAccountID equal to the policy account ID, otherwise the agent won't be listed at all. If we have a predetermined email those issues can be fixed. I guess this is not a blocker and can be handled in a follow up. I have updated the testings steps

pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
},
},
{
onyxMethod: Onyx.METHOD.SET,
Expand Down Expand Up @@ -128,8 +133,6 @@ function createAgent(
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.AGENT}${optimisticAccountID}`,
value: {
prompt,
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
errors: getMicroSecondOnyxErrorWithTranslationKey('agentsPage.error.genericAdd'),
},
},
Expand All @@ -150,11 +153,63 @@ function createAgent(
},
];

return {optimisticData, successData, failureData, optimisticAccountID, avatarURI, optimisticReportID, createdReportActionID, optimisticPersonalDetailPromise};
}

function createAgent(
firstName: string | undefined,
prompt: string,
currentUserAccountID: number,
currentUserLogin: string | undefined,
customExpensifyAvatarID?: string,
file?: File | CustomRNImageManipulatorResult,
optimisticAvatarURI?: string,
) {
const {optimisticData, successData, failureData, optimisticAccountID, avatarURI, optimisticReportID, createdReportActionID, optimisticPersonalDetailPromise} = prepareNewAgentOnyxData(
currentUserAccountID,
currentUserLogin,
prompt,
undefined,
firstName,
customExpensifyAvatarID,
optimisticAvatarURI,
);

write(
// Flag this as the user's personal agent; the backend makes personal agents a full co-pilot of the creator.
// optimisticReportID is the owner<->agent DM's optimistic reportID; CreateAgent creates the DM under this exact ID.
WRITE_COMMANDS.CREATE_AGENT,
{firstName, prompt, customExpensifyAvatarID, file, policyID, optimisticAccountID: String(optimisticAccountID), isPersonalAgent: true, optimisticReportID, createdReportActionID},
{firstName, prompt, customExpensifyAvatarID, file, optimisticAccountID: String(optimisticAccountID), isPersonalAgent: true, optimisticReportID, createdReportActionID},
{optimisticData, successData, failureData},
);

return {optimisticAccountID, avatarURI, optimisticReportID, optimisticPersonalDetailPromise};
}

function createCompanyAgent(
currentUserAccountID: number,
currentUserLogin: string | undefined,
prompt: string,
policy: Policy,
role: ValueOf<typeof CONST.POLICY.ROLE>,
firstName?: string,
customExpensifyAvatarID?: string,
file?: File | CustomRNImageManipulatorResult,
optimisticAvatarURI?: string,
) {
const {optimisticData, successData, failureData, optimisticAccountID, avatarURI, optimisticReportID, createdReportActionID, optimisticPersonalDetailPromise} = prepareNewAgentOnyxData(
currentUserAccountID,
currentUserLogin,
prompt,
policy,
firstName,
customExpensifyAvatarID,
optimisticAvatarURI,
);

write(
WRITE_COMMANDS.CREATE_COMPANY_AGENT,
{prompt, policyID: policy.id, role, firstName, customExpensifyAvatarID, file, optimisticAccountID, optimisticReportID, createdReportActionID},
{optimisticData, successData, failureData},
);

Expand Down Expand Up @@ -433,6 +488,7 @@ export {
openAgentsPage,
openProfilePage,
createAgent,
createCompanyAgent,
setNewAgentTemplate,
clearNewAgentTemplate,
clearAgentError,
Expand Down
Loading
Loading