Add global admin panel chat list with onboarding filter and enriched transcript (#23757)

https://github.com/user-attachments/assets/ee22d0d7-6ea0-4d49-a3d2-41ce19089943


Adds a cross-workspace chat list to the admin panel (admin-panel/chats,
linked from the AI tab) so we can analyze onboarding AI chats and
improve the workspace-setup prompts.

- Filters: onboarding only, has error, no user reply; search by
workspace, user email or thread id; server-side sort by message count,
replies, created or updated, with pagination. The list opens unfiltered
so every chat is visible by default.
- Onboarding threads are detected by fingerprint (hidden kickoff message
OR deterministic uuid v5 id), so all existing setup chats are covered
retroactively. The allowImpersonation gate is enforced in the query.
- Replies count answered `ask_questions` cards as well as user messages:
answering one writes no message row, only an in-place toolOutput update,
so those chats used to look abandoned.
- The admin transcript now returns the hidden kickoff prompt (collapsed
in the UI) and enriched message parts: reasoning, tool input/output
rendered as JSON trees, and errors. Reference chips are not navigable
there since they would link into the reader's own workspace.
- Fixes the workspace detail "Messages" column which displayed
conversationSize (tokens) instead of the message count.
This commit is contained in:
Raphaël Bosi
2026-08-07 15:08:02 +02:00
committed by GitHub
parent abd973ab4a
commit 7f0bae5b5f
55 changed files with 3270 additions and 114 deletions
@@ -56,23 +56,70 @@ export type AdminChatMessage = {
__typename?: 'AdminChatMessage';
createdAt: Scalars['DateTime']['output'];
id: Scalars['UUID']['output'];
isHidden: Scalars['Boolean']['output'];
parts: Array<AdminChatMessagePart>;
role: AgentMessageRole;
};
export type AdminChatMessagePart = {
__typename?: 'AdminChatMessagePart';
errorMessage?: Maybe<Scalars['String']['output']>;
orderIndex: Scalars['Int']['output'];
reasoningContent?: Maybe<Scalars['String']['output']>;
state?: Maybe<Scalars['String']['output']>;
textContent?: Maybe<Scalars['String']['output']>;
toolCallId?: Maybe<Scalars['String']['output']>;
toolInput?: Maybe<Scalars['JSON']['output']>;
toolName?: Maybe<Scalars['String']['output']>;
toolOutput?: Maybe<Scalars['JSON']['output']>;
type: Scalars['String']['output'];
};
export type AdminChatThreadListItem = {
__typename?: 'AdminChatThreadListItem';
createdAt: Scalars['DateTime']['output'];
deletedAt?: Maybe<Scalars['DateTime']['output']>;
hasError: Scalars['Boolean']['output'];
id: Scalars['UUID']['output'];
isOnboardingThread: Scalars['Boolean']['output'];
messageCount: Scalars['Int']['output'];
title?: Maybe<Scalars['String']['output']>;
updatedAt: Scalars['DateTime']['output'];
userEmail?: Maybe<Scalars['String']['output']>;
userFirstName?: Maybe<Scalars['String']['output']>;
userLastName?: Maybe<Scalars['String']['output']>;
userReplyCount: Scalars['Int']['output'];
userWorkspaceId: Scalars['UUID']['output'];
workspaceDisplayName?: Maybe<Scalars['String']['output']>;
workspaceId: Scalars['UUID']['output'];
};
export type AdminChatThreadMessages = {
__typename?: 'AdminChatThreadMessages';
messages: Array<AdminChatMessage>;
thread: AdminWorkspaceChatThread;
};
/** Scope of chat threads to list in the admin panel */
export enum AdminChatThreadScope {
ALL = 'ALL',
ONBOARDING = 'ONBOARDING'
}
/** Direction to sort admin chat threads */
export enum AdminChatThreadSortDirection {
ASC = 'ASC',
DESC = 'DESC'
}
/** Field to sort admin chat threads by */
export enum AdminChatThreadSortField {
CREATED_AT = 'CREATED_AT',
MESSAGE_COUNT = 'MESSAGE_COUNT',
REPLY_COUNT = 'REPLY_COUNT',
UPDATED_AT = 'UPDATED_AT'
}
export type AdminPanelHealthServiceData = {
__typename?: 'AdminPanelHealthServiceData';
description: Scalars['String']['output'];
@@ -169,6 +216,7 @@ export type AdminWorkspaceChatThread = {
conversationSize: Scalars['Int']['output'];
createdAt: Scalars['DateTime']['output'];
id: Scalars['UUID']['output'];
messageCount: Scalars['Int']['output'];
title?: Maybe<Scalars['String']['output']>;
totalInputTokens: Scalars['Int']['output'];
totalOutputTokens: Scalars['Int']['output'];
@@ -581,6 +629,13 @@ export type MutationUpdateWorkspaceFeatureFlagArgs = {
workspaceId: Scalars['UUID']['input'];
};
export type PaginatedAdminChatThreads = {
__typename?: 'PaginatedAdminChatThreads';
hasMore: Scalars['Boolean']['output'];
threads: Array<AdminChatThreadListItem>;
totalCount: Scalars['Int']['output'];
};
export type PaginatedApplicationRegistrations = {
__typename?: 'PaginatedApplicationRegistrations';
hasMore: Scalars['Boolean']['output'];
@@ -601,6 +656,7 @@ export type Query = {
getAdminAiModels: AdminAiModels;
getAdminAiUsageByWorkspace: Array<UsageBreakdownItem>;
getAdminChatThreadMessages: AdminChatThreadMessages;
getAdminChatThreads: PaginatedAdminChatThreads;
getAdminWorkspaceChatThreads: Array<AdminWorkspaceChatThread>;
getAiProviders: Scalars['JSON']['output'];
getConfigVariablesGrouped: ConfigVariables;
@@ -677,6 +733,18 @@ export type QueryGetAdminChatThreadMessagesArgs = {
};
export type QueryGetAdminChatThreadsArgs = {
hasErrorOnly?: InputMaybe<Scalars['Boolean']['input']>;
limit?: InputMaybe<Scalars['Int']['input']>;
offset?: InputMaybe<Scalars['Int']['input']>;
scope?: InputMaybe<AdminChatThreadScope>;
searchTerm?: InputMaybe<Scalars['String']['input']>;
sortBy?: InputMaybe<AdminChatThreadSortField>;
sortDirection?: InputMaybe<AdminChatThreadSortDirection>;
userNeverEngagedOnly?: InputMaybe<Scalars['Boolean']['input']>;
};
export type QueryGetAdminWorkspaceChatThreadsArgs = {
workspaceId: Scalars['UUID']['input'];
};
@@ -1215,14 +1283,28 @@ export type GetAdminChatThreadMessagesQueryVariables = Exact<{
}>;
export type GetAdminChatThreadMessagesQuery = { __typename?: 'Query', getAdminChatThreadMessages: { __typename?: 'AdminChatThreadMessages', thread: { __typename?: 'AdminWorkspaceChatThread', id: string, title?: string | null, totalInputTokens: number, totalOutputTokens: number, conversationSize: number, createdAt: string, updatedAt: string }, messages: Array<{ __typename?: 'AdminChatMessage', id: string, role: AgentMessageRole, createdAt: string, parts: Array<{ __typename?: 'AdminChatMessagePart', type: string, textContent?: string | null, toolName?: string | null }> }> } };
export type GetAdminChatThreadMessagesQuery = { __typename?: 'Query', getAdminChatThreadMessages: { __typename?: 'AdminChatThreadMessages', thread: { __typename?: 'AdminWorkspaceChatThread', id: string, title?: string | null, totalInputTokens: number, totalOutputTokens: number, conversationSize: number, messageCount: number, createdAt: string, updatedAt: string }, messages: Array<{ __typename?: 'AdminChatMessage', id: string, role: AgentMessageRole, isHidden: boolean, createdAt: string, parts: Array<{ __typename?: 'AdminChatMessagePart', type: string, orderIndex: number, textContent?: string | null, reasoningContent?: string | null, toolName?: string | null, toolCallId?: string | null, toolInput?: any | null, toolOutput?: any | null, state?: string | null, errorMessage?: string | null }> }> } };
export type GetAdminChatThreadsQueryVariables = Exact<{
scope?: InputMaybe<AdminChatThreadScope>;
hasErrorOnly?: InputMaybe<Scalars['Boolean']['input']>;
userNeverEngagedOnly?: InputMaybe<Scalars['Boolean']['input']>;
searchTerm?: InputMaybe<Scalars['String']['input']>;
sortBy?: InputMaybe<AdminChatThreadSortField>;
sortDirection?: InputMaybe<AdminChatThreadSortDirection>;
limit?: InputMaybe<Scalars['Int']['input']>;
offset?: InputMaybe<Scalars['Int']['input']>;
}>;
export type GetAdminChatThreadsQuery = { __typename?: 'Query', getAdminChatThreads: { __typename?: 'PaginatedAdminChatThreads', totalCount: number, hasMore: boolean, threads: Array<{ __typename?: 'AdminChatThreadListItem', id: string, title?: string | null, workspaceId: string, workspaceDisplayName?: string | null, userWorkspaceId: string, userEmail?: string | null, userFirstName?: string | null, userLastName?: string | null, messageCount: number, userReplyCount: number, hasError: boolean, isOnboardingThread: boolean, deletedAt?: string | null, createdAt: string, updatedAt: string }> } };
export type GetAdminWorkspaceChatThreadsQueryVariables = Exact<{
workspaceId: Scalars['UUID']['input'];
}>;
export type GetAdminWorkspaceChatThreadsQuery = { __typename?: 'Query', getAdminWorkspaceChatThreads: Array<{ __typename?: 'AdminWorkspaceChatThread', id: string, title?: string | null, totalInputTokens: number, totalOutputTokens: number, conversationSize: number, createdAt: string, updatedAt: string }> };
export type GetAdminWorkspaceChatThreadsQuery = { __typename?: 'Query', getAdminWorkspaceChatThreads: Array<{ __typename?: 'AdminWorkspaceChatThread', id: string, title?: string | null, totalInputTokens: number, totalOutputTokens: number, conversationSize: number, messageCount: number, createdAt: string, updatedAt: string }> };
export type GetServerAdminsQueryVariables = Exact<{ [key: string]: never; }>;
@@ -1385,8 +1467,9 @@ export const UpdateWorkspaceFeatureFlagDocument = {"kind":"Document","definition
export const AdminPanelRecentUsersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AdminPanelRecentUsers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"adminPanelRecentUsers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"searchTerm"},"value":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceName"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceLogo"}}]}}]}}]} as unknown as DocumentNode<AdminPanelRecentUsersQuery, AdminPanelRecentUsersQueryVariables>;
export const AdminPanelTopWorkspacesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AdminPanelTopWorkspaces"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"adminPanelTopWorkspaces"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"searchTerm"},"value":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"totalUsers"}},{"kind":"Field","name":{"kind":"Name","value":"subdomain"}}]}}]}}]} as unknown as DocumentNode<AdminPanelTopWorkspacesQuery, AdminPanelTopWorkspacesQueryVariables>;
export const FindOneAdminApplicationRegistrationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindOneAdminApplicationRegistration"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findOneAdminApplicationRegistration"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApplicationRegistrationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"galleryImagesUrls"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isVetted"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<FindOneAdminApplicationRegistrationQuery, FindOneAdminApplicationRegistrationQueryVariables>;
export const GetAdminChatThreadMessagesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAdminChatThreadMessages"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAdminChatThreadMessages"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"thread"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"conversationSize"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"messages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"parts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"textContent"}},{"kind":"Field","name":{"kind":"Name","value":"toolName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode<GetAdminChatThreadMessagesQuery, GetAdminChatThreadMessagesQueryVariables>;
export const GetAdminWorkspaceChatThreadsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAdminWorkspaceChatThreads"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAdminWorkspaceChatThreads"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"conversationSize"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<GetAdminWorkspaceChatThreadsQuery, GetAdminWorkspaceChatThreadsQueryVariables>;
export const GetAdminChatThreadMessagesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAdminChatThreadMessages"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAdminChatThreadMessages"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"thread"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"conversationSize"}},{"kind":"Field","name":{"kind":"Name","value":"messageCount"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"messages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"isHidden"}},{"kind":"Field","name":{"kind":"Name","value":"parts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"orderIndex"}},{"kind":"Field","name":{"kind":"Name","value":"textContent"}},{"kind":"Field","name":{"kind":"Name","value":"reasoningContent"}},{"kind":"Field","name":{"kind":"Name","value":"toolName"}},{"kind":"Field","name":{"kind":"Name","value":"toolCallId"}},{"kind":"Field","name":{"kind":"Name","value":"toolInput"}},{"kind":"Field","name":{"kind":"Name","value":"toolOutput"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode<GetAdminChatThreadMessagesQuery, GetAdminChatThreadMessagesQueryVariables>;
export const GetAdminChatThreadsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAdminChatThreads"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"scope"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"AdminChatThreadScope"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"hasErrorOnly"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userNeverEngagedOnly"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sortBy"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"AdminChatThreadSortField"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sortDirection"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"AdminChatThreadSortDirection"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offset"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAdminChatThreads"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"scope"},"value":{"kind":"Variable","name":{"kind":"Name","value":"scope"}}},{"kind":"Argument","name":{"kind":"Name","value":"hasErrorOnly"},"value":{"kind":"Variable","name":{"kind":"Name","value":"hasErrorOnly"}}},{"kind":"Argument","name":{"kind":"Name","value":"userNeverEngagedOnly"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userNeverEngagedOnly"}}},{"kind":"Argument","name":{"kind":"Name","value":"searchTerm"},"value":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}}},{"kind":"Argument","name":{"kind":"Name","value":"sortBy"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sortBy"}}},{"kind":"Argument","name":{"kind":"Name","value":"sortDirection"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sortDirection"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"offset"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offset"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"hasMore"}},{"kind":"Field","name":{"kind":"Name","value":"threads"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceDisplayName"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"userEmail"}},{"kind":"Field","name":{"kind":"Name","value":"userFirstName"}},{"kind":"Field","name":{"kind":"Name","value":"userLastName"}},{"kind":"Field","name":{"kind":"Name","value":"messageCount"}},{"kind":"Field","name":{"kind":"Name","value":"userReplyCount"}},{"kind":"Field","name":{"kind":"Name","value":"hasError"}},{"kind":"Field","name":{"kind":"Name","value":"isOnboardingThread"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]}}]} as unknown as DocumentNode<GetAdminChatThreadsQuery, GetAdminChatThreadsQueryVariables>;
export const GetAdminWorkspaceChatThreadsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAdminWorkspaceChatThreads"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAdminWorkspaceChatThreads"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"conversationSize"}},{"kind":"Field","name":{"kind":"Name","value":"messageCount"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<GetAdminWorkspaceChatThreadsQuery, GetAdminWorkspaceChatThreadsQueryVariables>;
export const GetServerAdminsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetServerAdmins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getServerAdmins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"canAccessFullAdminPanel"}},{"kind":"Field","name":{"kind":"Name","value":"canImpersonate"}}]}}]}}]} as unknown as DocumentNode<GetServerAdminsQuery, GetServerAdminsQueryVariables>;
export const GetUpgradeStatusDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUpgradeStatus"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceIds"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getUpgradeStatus"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceIds"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"inferredVersion"}},{"kind":"Field","name":{"kind":"Name","value":"health"}},{"kind":"Field","name":{"kind":"Name","value":"latestCommand"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"executedByVersion"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode<GetUpgradeStatusQuery, GetUpgradeStatusQueryVariables>;
export const GetVersionInfoDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetVersionInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"versionInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentVersion"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersion"}}]}}]}}]} as unknown as DocumentNode<GetVersionInfoQuery, GetVersionInfoQueryVariables>;
@@ -1,8 +1,10 @@
import { t } from '@lingui/core/macro';
import { type ReactNode } from 'react';
import { type ReactNode, useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { Chip, ChipVariant, LinkChip } from 'twenty-ui/data-display';
import { ChatReferenceNavigationEnabledContext } from '@/ai/contexts/ChatReferenceNavigationEnabledContext';
type ChatReferenceChipDisplayProps = {
displayName: string;
leftComponent: ReactNode;
@@ -14,13 +16,16 @@ export const ChatReferenceChipDisplay = ({
leftComponent,
to,
}: ChatReferenceChipDisplayProps) => {
if (!isDefined(to)) {
const isNavigationEnabled = useContext(ChatReferenceNavigationEnabledContext);
if (!isDefined(to) || !isNavigationEnabled) {
return (
<Chip
label={displayName}
emptyLabel={t`Untitled`}
variant={ChipVariant.Highlighted}
variant={ChipVariant.Static}
leftComponent={leftComponent}
clickable={false}
/>
);
}
@@ -0,0 +1,51 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { ChatReferenceChipDisplay } from '@/ai/components/ChatReferenceChipDisplay';
import { ChatReferenceNavigationEnabledContext } from '@/ai/contexts/ChatReferenceNavigationEnabledContext';
const renderChip = ({
isNavigationEnabled,
}: {
isNavigationEnabled: boolean;
}) =>
render(
<MemoryRouter>
<ChatReferenceNavigationEnabledContext.Provider
value={isNavigationEnabled}
>
<ChatReferenceChipDisplay
displayName="Acme"
leftComponent={null}
to="/objects/companies/acme-id"
/>
</ChatReferenceNavigationEnabledContext.Provider>
</MemoryRouter>,
);
describe('ChatReferenceChipDisplay', () => {
it('should link to the reference when navigation is enabled', () => {
renderChip({ isNavigationEnabled: true });
expect(screen.getByRole('link')).toHaveAttribute(
'href',
'/objects/companies/acme-id',
);
});
it('should render a plain chip when navigation is disabled', () => {
renderChip({ isNavigationEnabled: false });
expect(screen.queryByRole('link')).not.toBeInTheDocument();
expect(screen.getByText('Acme')).toBeInTheDocument();
});
it('should not look interactive when navigation is disabled', () => {
renderChip({ isNavigationEnabled: false });
const chipClassName = screen.getByTestId('chip').className;
expect(chipClassName).not.toMatch(/cursorPointer/);
expect(chipClassName).toMatch(/backgroundStatic/);
});
});
@@ -0,0 +1,3 @@
import { createContext } from 'react';
export const ChatReferenceNavigationEnabledContext = createContext(true);
@@ -621,6 +621,12 @@ const SettingsAdminWorkspaceChatThread = lazy(() =>
),
);
const SettingsAdminChats = lazy(() =>
import('~/pages/settings/admin-panel/SettingsAdminChats').then((module) => ({
default: module.SettingsAdminChats,
})),
);
const SettingsCommunity = lazy(() =>
import('~/pages/settings/community/SettingsCommunity').then((module) => ({
default: module.SettingsCommunity,
@@ -1094,6 +1100,10 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.AdminPanelWorkspaceChatThread}
element={<SettingsAdminWorkspaceChatThread />}
/>
<Route
path={SettingsPath.AdminPanelChats}
element={<SettingsAdminChats />}
/>
</>
)}
@@ -4,7 +4,9 @@ import { useMutation, useQuery } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconBolt, IconRobot } from 'twenty-ui/icon';
import { IconBolt, IconMessage, IconRobot } from 'twenty-ui/icon';
import { Button } from 'twenty-ui/input';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { H2Title } from 'twenty-ui/typography';
import { Section } from 'twenty-ui/layout';
import { Card } from 'twenty-ui/surfaces';
@@ -294,6 +296,21 @@ export const SettingsAdminAI = () => {
</Section>
)}
<Section>
<H2Title
title={t`Chats`}
description={t`Browse AI chat threads across all workspaces, including onboarding chats`}
/>
<UndecoratedLink to={getSettingsPath(SettingsPath.AdminPanelChats)}>
<Button
Icon={IconMessage}
title={t`View all chats`}
size="small"
variant="secondary"
/>
</UndecoratedLink>
</Section>
<Section>
<H2Title
title={t`AI Usage by Workspace`}
@@ -0,0 +1,50 @@
import { t } from '@lingui/core/macro';
import { styled } from '@linaria/react';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { SettingsAdminChatsTable } from '@/settings/admin-panel/chats/components/SettingsAdminChatsTable';
import { type AdminChatThreadListItem } from '@/settings/admin-panel/chats/types/AdminChatThreadListItem';
import { SettingsEmptyPlaceholder } from '@/settings/components/SettingsEmptyPlaceholder';
type SettingsAdminChatsContentProps = {
threads: AdminChatThreadListItem[];
loading: boolean;
error?: Error;
};
const StyledTableContainer = styled.div`
border-bottom: 1px solid ${themeCssVariables.border.color.light};
margin-top: ${themeCssVariables.spacing[3]};
`;
export const SettingsAdminChatsContent = ({
threads,
loading,
error,
}: SettingsAdminChatsContentProps) => {
if (isDefined(error)) {
return (
<SettingsEmptyPlaceholder>{t`Failed to load chats. Please try again.`}</SettingsEmptyPlaceholder>
);
}
if (loading && !isNonEmptyArray(threads)) {
return (
<SettingsEmptyPlaceholder>{t`Loading chats...`}</SettingsEmptyPlaceholder>
);
}
if (!isNonEmptyArray(threads)) {
return (
<SettingsEmptyPlaceholder>{t`No chats found`}</SettingsEmptyPlaceholder>
);
}
return (
<StyledTableContainer>
<SettingsAdminChatsTable threads={threads} />
</StyledTableContainer>
);
};
@@ -0,0 +1,73 @@
import { t } from '@lingui/core/macro';
import { type ReactNode } from 'react';
import { IconAlertTriangle, IconMessage, IconSparkles } from 'twenty-ui/icon';
import { MenuItemToggle } from 'twenty-ui/navigation';
import { type AdminChatsFilterState } from '@/settings/admin-panel/chats/types/AdminChatsFilterState';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
type SettingsAdminChatsFilterDropdownProps = {
filterButton: ReactNode;
filters: AdminChatsFilterState;
onFiltersChange: (filters: AdminChatsFilterState) => void;
};
export const SettingsAdminChatsFilterDropdown = ({
filterButton,
filters,
onFiltersChange,
}: SettingsAdminChatsFilterDropdownProps) => {
return (
<Dropdown
dropdownId="settings-admin-chats-filter-dropdown"
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 0, y: 8 }}
clickableComponent={filterButton}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconSparkles}
onToggleChange={() =>
onFiltersChange({
...filters,
onboardingOnly: !filters.onboardingOnly,
})
}
toggled={filters.onboardingOnly}
text={t`Onboarding only`}
toggleSize="small"
/>
<MenuItemToggle
LeftIcon={IconAlertTriangle}
onToggleChange={() =>
onFiltersChange({
...filters,
hasErrorOnly: !filters.hasErrorOnly,
})
}
toggled={filters.hasErrorOnly}
text={t`Has error`}
toggleSize="small"
/>
<MenuItemToggle
LeftIcon={IconMessage}
onToggleChange={() =>
onFiltersChange({
...filters,
userNeverEngagedOnly: !filters.userNeverEngagedOnly,
})
}
toggled={filters.userNeverEngagedOnly}
text={t`No user reply`}
toggleSize="small"
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
);
};
@@ -0,0 +1,58 @@
import { t } from '@lingui/core/macro';
import { SETTINGS_ADMIN_CHATS_TABLE_GRID } from '@/settings/admin-panel/chats/constants/SettingsAdminChatsTableGrid';
import { SETTINGS_ADMIN_CHATS_TABLE_ID } from '@/settings/admin-panel/chats/constants/SettingsAdminChatsTableId';
import { SettingsAdminChatsTableRow } from '@/settings/admin-panel/chats/components/SettingsAdminChatsTableRow';
import { type AdminChatThreadListItem } from '@/settings/admin-panel/chats/types/AdminChatThreadListItem';
import { SortableTableHeader } from '@/ui/layout/table/components/SortableTableHeader';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { AdminChatThreadSortField } from '~/generated-admin/graphql';
type SettingsAdminChatsTableProps = {
threads: AdminChatThreadListItem[];
};
export const SettingsAdminChatsTable = ({
threads,
}: SettingsAdminChatsTableProps) => {
return (
<Table>
<TableRow gridAutoColumns={SETTINGS_ADMIN_CHATS_TABLE_GRID}>
<TableHeader>{t`Workspace`}</TableHeader>
<TableHeader>{t`User`}</TableHeader>
<TableHeader>{t`Title`}</TableHeader>
<SortableTableHeader
tableId={SETTINGS_ADMIN_CHATS_TABLE_ID}
fieldName={AdminChatThreadSortField.MESSAGE_COUNT}
label={t`Msgs`}
align="right"
/>
<SortableTableHeader
tableId={SETTINGS_ADMIN_CHATS_TABLE_ID}
fieldName={AdminChatThreadSortField.REPLY_COUNT}
label={t`Replies`}
align="right"
/>
<TableHeader>{t`Flags`}</TableHeader>
<SortableTableHeader
tableId={SETTINGS_ADMIN_CHATS_TABLE_ID}
fieldName={AdminChatThreadSortField.CREATED_AT}
label={t`Created`}
align="right"
initialSort={{
fieldName: AdminChatThreadSortField.CREATED_AT,
orderBy: 'DescNullsLast',
}}
/>
</TableRow>
<TableBody>
{threads.map((thread) => (
<SettingsAdminChatsTableRow key={thread.id} thread={thread} />
))}
</TableBody>
</Table>
);
};
@@ -0,0 +1,89 @@
import { t } from '@lingui/core/macro';
import { styled } from '@linaria/react';
import { isNonEmptyString } from '@sniptt/guards';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { Tag } from 'twenty-ui/data-display';
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { SETTINGS_ADMIN_CHATS_TABLE_GRID } from '@/settings/admin-panel/chats/constants/SettingsAdminChatsTableGrid';
import { type AdminChatThreadListItem } from '@/settings/admin-panel/chats/types/AdminChatThreadListItem';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
type SettingsAdminChatsTableRowProps = {
thread: AdminChatThreadListItem;
};
const StyledFlagsContainer = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[1]};
overflow: hidden;
`;
const StyledZeroReplies = styled.span`
color: ${themeCssVariables.color.red};
`;
export const SettingsAdminChatsTableRow = ({
thread,
}: SettingsAdminChatsTableRowProps) => {
return (
<TableRow
to={getSettingsPath(SettingsPath.AdminPanelWorkspaceChatThread, {
workspaceId: thread.workspaceId,
threadId: thread.id,
})}
gridAutoColumns={SETTINGS_ADMIN_CHATS_TABLE_GRID}
isClickable
>
<TableCell minWidth="0" overflow="hidden">
<OverflowingTextWithTooltip
text={
isNonEmptyString(thread.workspaceDisplayName)
? thread.workspaceDisplayName
: thread.workspaceId
}
/>
</TableCell>
<TableCell minWidth="0" overflow="hidden">
<OverflowingTextWithTooltip
text={isNonEmptyString(thread.userEmail) ? thread.userEmail : '-'}
/>
</TableCell>
<TableCell
color={themeCssVariables.font.color.primary}
minWidth="0"
overflow="hidden"
>
<OverflowingTextWithTooltip
text={isNonEmptyString(thread.title) ? thread.title : t`Untitled`}
/>
</TableCell>
<TableCell align="right">{thread.messageCount}</TableCell>
<TableCell align="right">
{thread.userReplyCount === 0 ? (
<StyledZeroReplies>{thread.userReplyCount}</StyledZeroReplies>
) : (
thread.userReplyCount
)}
</TableCell>
<TableCell minWidth="0" overflow="hidden">
<StyledFlagsContainer>
{thread.hasError && <Tag color="red" text={t`Error`} />}
{isDefined(thread.deletedAt) && (
<Tag color="gray" text={t`Archived`} />
)}
{thread.isOnboardingThread && (
<Tag color="blue" text={t`Onboarding`} />
)}
</StyledFlagsContainer>
</TableCell>
<TableCell align="right">
{new Date(thread.createdAt).toLocaleDateString()}
</TableCell>
</TableRow>
);
};
@@ -0,0 +1,7 @@
import { type AdminChatsFilterState } from '@/settings/admin-panel/chats/types/AdminChatsFilterState';
export const DEFAULT_ADMIN_CHATS_FILTER_STATE: AdminChatsFilterState = {
onboardingOnly: false,
hasErrorOnly: false,
userNeverEngagedOnly: false,
};
@@ -0,0 +1,2 @@
export const SETTINGS_ADMIN_CHATS_TABLE_GRID =
'1fr 1fr 1fr 80px 80px 110px 90px';
@@ -0,0 +1 @@
export const SETTINGS_ADMIN_CHATS_TABLE_ID = 'settings-admin-chats-table';
@@ -0,0 +1,120 @@
import { useQuery } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { useDebounce } from 'use-debounce';
import { isDefined } from 'twenty-shared/utils';
import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient';
import { DEFAULT_ADMIN_CHATS_FILTER_STATE } from '@/settings/admin-panel/chats/constants/DefaultAdminChatsFilterState';
import { SETTINGS_ADMIN_CHATS_TABLE_ID } from '@/settings/admin-panel/chats/constants/SettingsAdminChatsTableId';
import { type AdminChatsFilterState } from '@/settings/admin-panel/chats/types/AdminChatsFilterState';
import { getAdminChatsSortVariables } from '@/settings/admin-panel/chats/utils/getAdminChatsSortVariables';
import { GET_ADMIN_CHAT_THREADS } from '@/settings/admin-panel/graphql/queries/getAdminChatThreads';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { sortedFieldByTableFamilyState } from '@/ui/layout/table/states/sortedFieldByTableFamilyState';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import {
AdminChatThreadScope,
type GetAdminChatThreadsQuery,
type GetAdminChatThreadsQueryVariables,
} from '~/generated-admin/graphql';
const PAGE_SIZE = 25;
export const useAdminChatThreads = () => {
const apolloAdminClient = useApolloAdminClient();
const { enqueueErrorSnackBar } = useSnackBar();
const [searchQuery, setSearchQuery] = useState('');
const [debouncedSearchQuery] = useDebounce(searchQuery, 300);
const [filters, setFilters] = useState<AdminChatsFilterState>(
DEFAULT_ADMIN_CHATS_FILTER_STATE,
);
const sortedFieldByTable = useAtomFamilyStateValue(
sortedFieldByTableFamilyState,
{
tableId: SETTINGS_ADMIN_CHATS_TABLE_ID,
},
);
const { sortBy, sortDirection } =
getAdminChatsSortVariables(sortedFieldByTable);
const { data, loading, error, fetchMore } = useQuery<
GetAdminChatThreadsQuery,
GetAdminChatThreadsQueryVariables
>(GET_ADMIN_CHAT_THREADS, {
client: apolloAdminClient,
notifyOnNetworkStatusChange: true,
variables: {
limit: PAGE_SIZE,
offset: 0,
searchTerm: debouncedSearchQuery,
scope: filters.onboardingOnly
? AdminChatThreadScope.ONBOARDING
: AdminChatThreadScope.ALL,
hasErrorOnly: filters.hasErrorOnly,
userNeverEngagedOnly: filters.userNeverEngagedOnly,
sortBy,
sortDirection,
},
});
const threads = data?.getAdminChatThreads.threads ?? [];
const totalCount = data?.getAdminChatThreads.totalCount ?? 0;
const hasMore = data?.getAdminChatThreads.hasMore ?? false;
const isShowMoreDisabled = loading || searchQuery !== debouncedSearchQuery;
const handleShowMore = async () => {
if (isShowMoreDisabled) {
return;
}
try {
await fetchMore({
variables: {
limit: PAGE_SIZE,
offset: threads.length,
},
updateQuery: (previousData, { fetchMoreResult }) => {
if (!isDefined(fetchMoreResult)) {
return previousData;
}
const previousThreadIds = new Set(
previousData.getAdminChatThreads.threads.map((thread) => thread.id),
);
return {
getAdminChatThreads: {
...fetchMoreResult.getAdminChatThreads,
threads: [
...previousData.getAdminChatThreads.threads,
...fetchMoreResult.getAdminChatThreads.threads.filter(
(thread) => !previousThreadIds.has(thread.id),
),
],
},
};
},
});
} catch {
enqueueErrorSnackBar({ message: t`Failed to load more chats.` });
}
};
return {
searchQuery,
setSearchQuery,
filters,
setFilters,
threads,
totalCount,
hasMore,
loading,
isShowMoreDisabled,
error,
handleShowMore,
};
};
@@ -0,0 +1,5 @@
import { type GetAdminChatThreadsQuery } from '~/generated-admin/graphql';
export type AdminChatThreadListItem = NonNullable<
GetAdminChatThreadsQuery['getAdminChatThreads']
>['threads'][number];
@@ -0,0 +1,5 @@
export type AdminChatsFilterState = {
onboardingOnly: boolean;
hasErrorOnly: boolean;
userNeverEngagedOnly: boolean;
};
@@ -0,0 +1,50 @@
import { getAdminChatsSortVariables } from '@/settings/admin-panel/chats/utils/getAdminChatsSortVariables';
import {
AdminChatThreadSortDirection,
AdminChatThreadSortField,
} from '~/generated-admin/graphql';
describe('getAdminChatsSortVariables', () => {
it('should default to created at descending when no sort is set', () => {
expect(getAdminChatsSortVariables(null)).toEqual({
sortBy: AdminChatThreadSortField.CREATED_AT,
sortDirection: AdminChatThreadSortDirection.DESC,
});
});
it('should default when the sorted field is not a server sort field', () => {
expect(
getAdminChatsSortVariables({
fieldName: 'unknownField',
orderBy: 'AscNullsLast',
}),
).toEqual({
sortBy: AdminChatThreadSortField.CREATED_AT,
sortDirection: AdminChatThreadSortDirection.DESC,
});
});
it('should map an ascending message count sort', () => {
expect(
getAdminChatsSortVariables({
fieldName: AdminChatThreadSortField.MESSAGE_COUNT,
orderBy: 'AscNullsLast',
}),
).toEqual({
sortBy: AdminChatThreadSortField.MESSAGE_COUNT,
sortDirection: AdminChatThreadSortDirection.ASC,
});
});
it('should map a descending updated at sort', () => {
expect(
getAdminChatsSortVariables({
fieldName: AdminChatThreadSortField.UPDATED_AT,
orderBy: 'DescNullsLast',
}),
).toEqual({
sortBy: AdminChatThreadSortField.UPDATED_AT,
sortDirection: AdminChatThreadSortDirection.DESC,
});
});
});
@@ -0,0 +1,40 @@
import { isDefined } from 'twenty-shared/utils';
import { type TableSortValue } from '@/ui/layout/table/types/TableSortValue';
import {
AdminChatThreadSortDirection,
AdminChatThreadSortField,
} from '~/generated-admin/graphql';
type AdminChatsSortVariables = {
sortBy: AdminChatThreadSortField;
sortDirection: AdminChatThreadSortDirection;
};
const isAdminChatThreadSortField = (
fieldName: string,
): fieldName is AdminChatThreadSortField =>
Object.values(AdminChatThreadSortField).includes(
fieldName as AdminChatThreadSortField,
);
export const getAdminChatsSortVariables = (
sortValue: TableSortValue | null,
): AdminChatsSortVariables => {
if (
!isDefined(sortValue) ||
!isAdminChatThreadSortField(sortValue.fieldName)
) {
return {
sortBy: AdminChatThreadSortField.CREATED_AT,
sortDirection: AdminChatThreadSortDirection.DESC,
};
}
return {
sortBy: sortValue.fieldName,
sortDirection: sortValue.orderBy.startsWith('Asc')
? AdminChatThreadSortDirection.ASC
: AdminChatThreadSortDirection.DESC,
};
};
@@ -0,0 +1,62 @@
import { styled } from '@linaria/react';
import { type ReactNode, useState } from 'react';
import { IconChevronDown, IconChevronUp } from 'twenty-ui/icon';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type SettingsAdminChatCollapsibleSectionProps = {
label: string;
defaultExpanded?: boolean;
children: ReactNode;
};
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[1]};
width: 100%;
`;
const StyledToggleButton = styled.button`
align-items: center;
background: none;
border: none;
color: ${themeCssVariables.font.color.tertiary};
cursor: pointer;
display: flex;
font-size: ${themeCssVariables.font.size.sm};
font-weight: ${themeCssVariables.font.weight.medium};
gap: ${themeCssVariables.spacing[1]};
padding: 0;
width: fit-content;
`;
export const SettingsAdminChatCollapsibleSection = ({
label,
defaultExpanded = false,
children,
}: SettingsAdminChatCollapsibleSectionProps) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
return (
<StyledContainer>
<StyledToggleButton
aria-expanded={isExpanded}
onClick={() =>
setIsExpanded((previousIsExpanded) => !previousIsExpanded)
}
>
{label}
{isExpanded ? (
<IconChevronUp size={14} />
) : (
<IconChevronDown size={14} />
)}
</StyledToggleButton>
<AnimatedExpandableContainer isExpanded={isExpanded} mode="fit-content">
{children}
</AnimatedExpandableContainer>
</StyledContainer>
);
};
@@ -0,0 +1,70 @@
import { t } from '@lingui/core/macro';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { SettingsAdminChatCollapsibleSection } from '@/settings/admin-panel/components/SettingsAdminChatCollapsibleSection';
import { SettingsAdminChatMessagePartRenderer } from '@/settings/admin-panel/components/SettingsAdminChatMessagePartRenderer';
import { type AdminChatThreadMessage } from '@/settings/admin-panel/types/AdminChatThreadMessage';
import { isRenderableAdminChatMessagePart } from '@/settings/admin-panel/utils/isRenderableAdminChatMessagePart';
import { AgentMessageRole } from '~/generated-admin/graphql';
type SettingsAdminChatMessageProps = {
message: AdminChatThreadMessage;
};
const StyledMessageBubble = styled.div<{ isUser?: boolean }>`
align-items: ${({ isUser }) => (isUser ? 'flex-end' : 'flex-start')};
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
width: 100%;
`;
const StyledRoleLabel = styled.span`
color: ${themeCssVariables.font.color.light};
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.medium};
text-transform: capitalize;
`;
const StyledTimestamp = styled.span`
color: ${themeCssVariables.font.color.light};
font-size: ${themeCssVariables.font.size.xs};
`;
export const SettingsAdminChatMessage = ({
message,
}: SettingsAdminChatMessageProps) => {
const isUser = message.role === AgentMessageRole.USER;
const renderableParts = message.parts
.filter(isRenderableAdminChatMessagePart)
.sort((a, b) => a.orderIndex - b.orderIndex);
const messageBody = (
<StyledMessageBubble isUser={isUser && !message.isHidden}>
{!message.isHidden && <StyledRoleLabel>{message.role}</StyledRoleLabel>}
{renderableParts.map((part) => (
<SettingsAdminChatMessagePartRenderer
key={part.orderIndex}
part={part}
isUserMessage={isUser}
/>
))}
<StyledTimestamp>
{new Date(message.createdAt).toLocaleString()}
</StyledTimestamp>
</StyledMessageBubble>
);
if (message.isHidden) {
return (
<SettingsAdminChatCollapsibleSection label={t`Kickoff prompt`}>
{messageBody}
</SettingsAdminChatCollapsibleSection>
);
}
return messageBody;
};
@@ -0,0 +1,73 @@
import { t } from '@lingui/core/macro';
import { styled } from '@linaria/react';
import { isNonEmptyString } from '@sniptt/guards';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { LazyMarkdownRenderer } from '@/ai/components/LazyMarkdownRenderer';
import { SettingsAdminChatCollapsibleSection } from '@/settings/admin-panel/components/SettingsAdminChatCollapsibleSection';
import { SettingsAdminChatToolCallPart } from '@/settings/admin-panel/components/SettingsAdminChatToolCallPart';
import { type AdminChatThreadMessagePart } from '@/settings/admin-panel/types/AdminChatThreadMessagePart';
type SettingsAdminChatMessagePartRendererProps = {
part: AdminChatThreadMessagePart;
isUserMessage: boolean;
};
const StyledTextContent = styled.div<{ isUser?: boolean }>`
background: ${({ isUser }) =>
isUser ? themeCssVariables.background.tertiary : 'transparent'};
border-radius: ${themeCssVariables.border.radius.sm};
color: ${({ isUser }) =>
isUser
? themeCssVariables.font.color.secondary
: themeCssVariables.font.color.primary};
font-weight: ${({ isUser }) =>
isUser
? themeCssVariables.font.weight.medium
: themeCssVariables.font.weight.regular};
line-height: 1.4em;
max-width: 100%;
overflow-wrap: break-word;
padding: ${({ isUser }) =>
isUser ? `0 ${themeCssVariables.spacing[2]}` : '0'};
white-space: ${({ isUser }) => (isUser ? 'pre-wrap' : 'normal')};
width: ${({ isUser }) => (isUser ? 'fit-content' : '100%')};
`;
const StyledReasoningContent = styled.div`
background: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.light};
border-radius: ${themeCssVariables.border.radius.sm};
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.sm};
padding: ${themeCssVariables.spacing[3]};
white-space: pre-wrap;
`;
export const SettingsAdminChatMessagePartRenderer = ({
part,
isUserMessage,
}: SettingsAdminChatMessagePartRendererProps) => {
if (part.type === 'text' && isNonEmptyString(part.textContent)) {
return (
<StyledTextContent isUser={isUserMessage}>
{isUserMessage ? (
part.textContent
) : (
<LazyMarkdownRenderer text={part.textContent} />
)}
</StyledTextContent>
);
}
if (part.type === 'reasoning' && isNonEmptyString(part.reasoningContent)) {
return (
<SettingsAdminChatCollapsibleSection label={t`Reasoning`}>
<StyledReasoningContent>{part.reasoningContent}</StyledReasoningContent>
</SettingsAdminChatCollapsibleSection>
);
}
return <SettingsAdminChatToolCallPart part={part} />;
};
@@ -1,22 +1,20 @@
import { t } from '@lingui/core/macro';
import { styled } from '@linaria/react';
import { LazyMarkdownRenderer } from '@/ai/components/LazyMarkdownRenderer';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { isNonEmptyArray } from 'twenty-shared/utils';
import { Card } from 'twenty-ui/surfaces';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
AgentMessageRole,
type GetAdminChatThreadMessagesQuery,
} from '~/generated-admin/graphql';
import { AgentMessageRole } from '~/generated-admin/graphql';
type ChatMessage = NonNullable<
GetAdminChatThreadMessagesQuery['getAdminChatThreadMessages']
>['messages'][number];
import { ChatReferenceNavigationEnabledContext } from '@/ai/contexts/ChatReferenceNavigationEnabledContext';
import { SettingsAdminChatMessage } from '@/settings/admin-panel/components/SettingsAdminChatMessage';
import { type AdminChatThreadMessage } from '@/settings/admin-panel/types/AdminChatThreadMessage';
import { isRenderableAdminChatMessagePart } from '@/settings/admin-panel/utils/isRenderableAdminChatMessagePart';
type SettingsAdminChatThreadMessageListProps = {
messages: ChatMessage[];
messages: AdminChatThreadMessage[];
};
const StyledMessagesContainer = styled.div`
@@ -25,53 +23,16 @@ const StyledMessagesContainer = styled.div`
gap: ${themeCssVariables.spacing[4]};
`;
const StyledMessageBubble = styled.div<{ isUser?: boolean }>`
align-items: ${({ isUser }) => (isUser ? 'flex-end' : 'flex-start')};
display: flex;
flex-direction: column;
width: 100%;
`;
const StyledMessageContent = styled.div<{ isUser?: boolean }>`
background: ${({ isUser }) =>
isUser ? themeCssVariables.background.tertiary : 'transparent'};
border-radius: ${themeCssVariables.border.radius.sm};
color: ${({ isUser }) =>
isUser
? themeCssVariables.font.color.secondary
: themeCssVariables.font.color.primary};
font-weight: ${({ isUser }) => (isUser ? 500 : 400)};
line-height: 1.4em;
max-width: 100%;
overflow-wrap: break-word;
padding: ${({ isUser }) =>
isUser ? `0 ${themeCssVariables.spacing[2]}` : '0'};
white-space: ${({ isUser }) => (isUser ? 'pre-wrap' : 'normal')};
width: ${({ isUser }) => (isUser ? 'fit-content' : '100%')};
`;
const StyledRoleLabel = styled.span`
color: ${themeCssVariables.font.color.light};
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.medium};
margin-bottom: ${themeCssVariables.spacing[1]};
text-transform: capitalize;
`;
const StyledTimestamp = styled.span`
color: ${themeCssVariables.font.color.light};
font-size: ${themeCssVariables.font.size.xs};
margin-top: ${themeCssVariables.spacing[1]};
`;
export const SettingsAdminChatThreadMessageList = ({
messages,
}: SettingsAdminChatThreadMessageListProps) => {
const visibleMessages = messages.filter(
(message) => message.role !== AgentMessageRole.SYSTEM,
(message) =>
message.role !== AgentMessageRole.SYSTEM &&
message.parts.some(isRenderableAdminChatMessagePart),
);
if (visibleMessages.length === 0) {
if (!isNonEmptyArray(visibleMessages)) {
return (
<Card rounded>
<TableRow gridTemplateColumns="1fr">
@@ -87,41 +48,12 @@ export const SettingsAdminChatThreadMessageList = ({
}
return (
<StyledMessagesContainer>
{visibleMessages.map((message) => {
const isUser = message.role === AgentMessageRole.USER;
const textParts = message.parts
.filter((part) => part.type === 'text' && part.textContent !== null)
.map((part) => part.textContent)
.join('\n');
const toolParts = message.parts.filter(
(part) => part.type === 'tool-call' && part.toolName !== null,
);
if (textParts.length === 0 && toolParts.length === 0) {
return null;
}
return (
<StyledMessageBubble key={message.id} isUser={isUser}>
<StyledRoleLabel>{message.role}</StyledRoleLabel>
{textParts.length > 0 && (
<StyledMessageContent isUser={isUser}>
{isUser ? textParts : <LazyMarkdownRenderer text={textParts} />}
</StyledMessageContent>
)}
{toolParts.map((part, index) => (
<StyledMessageContent key={index} isUser={false}>
{t`Tool call: ${part.toolName ?? ''}`}
</StyledMessageContent>
))}
<StyledTimestamp>
{new Date(message.createdAt).toLocaleString()}
</StyledTimestamp>
</StyledMessageBubble>
);
})}
</StyledMessagesContainer>
<ChatReferenceNavigationEnabledContext.Provider value={false}>
<StyledMessagesContainer>
{visibleMessages.map((message) => (
<SettingsAdminChatMessage key={message.id} message={message} />
))}
</StyledMessagesContainer>
</ChatReferenceNavigationEnabledContext.Provider>
);
};
@@ -0,0 +1,178 @@
import { t } from '@lingui/core/macro';
import { styled } from '@linaria/react';
import { useState } from 'react';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { Tag } from 'twenty-ui/data-display';
import { IconChevronDown, IconChevronUp, IconTool } from 'twenty-ui/icon';
import { JsonTree } from 'twenty-ui/json-visualizer';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type AdminChatThreadMessagePart } from '@/settings/admin-panel/types/AdminChatThreadMessagePart';
import { getAdminToolDisplayName } from '@/settings/admin-panel/utils/getAdminToolDisplayName';
import { parseAdminToolJson } from '@/settings/admin-panel/utils/parseAdminToolJson';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
type SettingsAdminChatToolCallPartProps = {
part: AdminChatThreadMessagePart;
};
const StyledContainer = styled.div`
border: 1px solid ${themeCssVariables.border.color.light};
border-radius: ${themeCssVariables.border.radius.sm};
display: flex;
flex-direction: column;
padding: ${themeCssVariables.spacing[2]};
width: 100%;
`;
const StyledToggleRow = styled.button`
align-items: center;
background: none;
border: none;
cursor: pointer;
display: flex;
gap: ${themeCssVariables.spacing[2]};
justify-content: space-between;
padding: 0;
width: 100%;
`;
const StyledToolLabel = styled.span`
align-items: center;
color: ${themeCssVariables.font.color.secondary};
display: flex;
font-family: ${themeCssVariables.font.family};
font-size: ${themeCssVariables.font.size.sm};
font-weight: ${themeCssVariables.font.weight.medium};
gap: ${themeCssVariables.spacing[1]};
`;
const StyledRightContent = styled.span`
align-items: center;
color: ${themeCssVariables.font.color.tertiary};
display: flex;
gap: ${themeCssVariables.spacing[1]};
`;
const StyledTabContainer = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[2]};
margin-top: ${themeCssVariables.spacing[2]};
`;
const StyledTab = styled.button<{ isActive: boolean }>`
background: none;
border: none;
border-bottom: 1px solid
${({ isActive }) =>
isActive ? themeCssVariables.font.color.primary : 'transparent'};
color: ${({ isActive }) =>
isActive
? themeCssVariables.font.color.primary
: themeCssVariables.font.color.tertiary};
cursor: pointer;
font-size: ${themeCssVariables.font.size.sm};
padding: ${themeCssVariables.spacing[1]} 0;
`;
const StyledJsonTreeContainer = styled.div`
margin-top: ${themeCssVariables.spacing[2]};
max-height: 300px;
overflow: auto;
`;
const StyledErrorMessage = styled.div`
color: ${themeCssVariables.color.red};
font-size: ${themeCssVariables.font.size.sm};
margin-top: ${themeCssVariables.spacing[2]};
white-space: pre-wrap;
`;
const StyledEmptyTabLabel = styled.div`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.sm};
margin-top: ${themeCssVariables.spacing[2]};
`;
export const SettingsAdminChatToolCallPart = ({
part,
}: SettingsAdminChatToolCallPartProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const [activeTab, setActiveTab] = useState<'output' | 'input'>(
isDefined(part.toolOutput) ? 'output' : 'input',
);
const { copyToClipboard } = useCopyToClipboard();
const toolName = getAdminToolDisplayName(part);
const hasToolError =
part.state === 'output-error' || isNonEmptyString(part.errorMessage);
const activeJsonValue = parseAdminToolJson(
activeTab === 'output' ? part.toolOutput : part.toolInput,
);
return (
<StyledContainer>
<StyledToggleRow
aria-expanded={isExpanded}
onClick={() =>
setIsExpanded((previousIsExpanded) => !previousIsExpanded)
}
>
<StyledToolLabel>
<IconTool size={14} />
{toolName}
{hasToolError && <Tag color="red" text={t`Failed`} />}
</StyledToolLabel>
<StyledRightContent>
{isExpanded ? (
<IconChevronUp size={14} />
) : (
<IconChevronDown size={14} />
)}
</StyledRightContent>
</StyledToggleRow>
<AnimatedExpandableContainer isExpanded={isExpanded} mode="fit-content">
<StyledTabContainer>
<StyledTab
isActive={activeTab === 'output'}
onClick={() => setActiveTab('output')}
>
{t`Output`}
</StyledTab>
<StyledTab
isActive={activeTab === 'input'}
onClick={() => setActiveTab('input')}
>
{t`Input`}
</StyledTab>
</StyledTabContainer>
{isDefined(activeJsonValue) ? (
<StyledJsonTreeContainer>
<JsonTree
value={activeJsonValue}
shouldExpandNodeInitially={() => false}
emptyArrayLabel={t`Empty Array`}
emptyObjectLabel={t`Empty Object`}
emptyStringLabel={t`[empty string]`}
arrowButtonCollapsedLabel={t`Expand`}
arrowButtonExpandedLabel={t`Collapse`}
onNodeValueClick={copyToClipboard}
/>
</StyledJsonTreeContainer>
) : (
<StyledEmptyTabLabel>
{activeTab === 'output' ? t`No output` : t`No input`}
</StyledEmptyTabLabel>
)}
{isNonEmptyString(part.errorMessage) && (
<StyledErrorMessage>{part.errorMessage}</StyledErrorMessage>
)}
</AnimatedExpandableContainer>
</StyledContainer>
);
};
@@ -9,16 +9,25 @@ export const GET_ADMIN_CHAT_THREAD_MESSAGES = gql`
totalInputTokens
totalOutputTokens
conversationSize
messageCount
createdAt
updatedAt
}
messages {
id
role
isHidden
parts {
type
orderIndex
textContent
reasoningContent
toolName
toolCallId
toolInput
toolOutput
state
errorMessage
}
createdAt
}
@@ -0,0 +1,45 @@
import { gql } from '@apollo/client';
export const GET_ADMIN_CHAT_THREADS = gql`
query GetAdminChatThreads(
$scope: AdminChatThreadScope
$hasErrorOnly: Boolean
$userNeverEngagedOnly: Boolean
$searchTerm: String
$sortBy: AdminChatThreadSortField
$sortDirection: AdminChatThreadSortDirection
$limit: Int
$offset: Int
) {
getAdminChatThreads(
scope: $scope
hasErrorOnly: $hasErrorOnly
userNeverEngagedOnly: $userNeverEngagedOnly
searchTerm: $searchTerm
sortBy: $sortBy
sortDirection: $sortDirection
limit: $limit
offset: $offset
) {
totalCount
hasMore
threads {
id
title
workspaceId
workspaceDisplayName
userWorkspaceId
userEmail
userFirstName
userLastName
messageCount
userReplyCount
hasError
isOnboardingThread
deletedAt
createdAt
updatedAt
}
}
}
`;
@@ -8,6 +8,7 @@ export const GET_ADMIN_WORKSPACE_CHAT_THREADS = gql`
totalInputTokens
totalOutputTokens
conversationSize
messageCount
createdAt
updatedAt
}
@@ -0,0 +1,5 @@
import { type GetAdminChatThreadMessagesQuery } from '~/generated-admin/graphql';
export type AdminChatThreadMessage = NonNullable<
GetAdminChatThreadMessagesQuery['getAdminChatThreadMessages']
>['messages'][number];
@@ -0,0 +1,4 @@
import { type AdminChatThreadMessage } from '@/settings/admin-panel/types/AdminChatThreadMessage';
export type AdminChatThreadMessagePart =
AdminChatThreadMessage['parts'][number];
@@ -0,0 +1,29 @@
import { type AdminChatThreadMessagePart } from '@/settings/admin-panel/types/AdminChatThreadMessagePart';
import { getAdminToolDisplayName } from '@/settings/admin-panel/utils/getAdminToolDisplayName';
const buildPart = (
part: Partial<AdminChatThreadMessagePart>,
): AdminChatThreadMessagePart =>
({
type: 'tool-ask_questions',
toolName: null,
...part,
}) as AdminChatThreadMessagePart;
describe('getAdminToolDisplayName', () => {
it('should prefer the persisted tool name', () => {
expect(
getAdminToolDisplayName(buildPart({ toolName: 'ask_questions' })),
).toBe('ask_questions');
});
it('should strip the tool prefix from the part type', () => {
expect(getAdminToolDisplayName(buildPart({}))).toBe('ask_questions');
});
it('should fall back to the raw type', () => {
expect(getAdminToolDisplayName(buildPart({ type: 'dynamic-tool' }))).toBe(
'dynamic-tool',
);
});
});
@@ -0,0 +1,22 @@
import { parseAdminToolJson } from '@/settings/admin-panel/utils/parseAdminToolJson';
describe('parseAdminToolJson', () => {
it('should return null for null or undefined', () => {
expect(parseAdminToolJson(null)).toBeNull();
expect(parseAdminToolJson(undefined)).toBeNull();
});
it('should parse a JSON string', () => {
expect(parseAdminToolJson('{"success":true}')).toEqual({ success: true });
});
it('should return the raw string when it is not valid JSON', () => {
expect(parseAdminToolJson('not json')).toBe('not json');
});
it('should pass objects through unchanged', () => {
expect(parseAdminToolJson({ objects: [1, 2] })).toEqual({
objects: [1, 2],
});
});
});
@@ -0,0 +1,19 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type AdminChatThreadMessagePart } from '@/settings/admin-panel/types/AdminChatThreadMessagePart';
const TOOL_PART_TYPE_PREFIX = 'tool-';
export const getAdminToolDisplayName = (
part: AdminChatThreadMessagePart,
): string => {
if (isNonEmptyString(part.toolName)) {
return part.toolName;
}
if (part.type.startsWith(TOOL_PART_TYPE_PREFIX)) {
return part.type.slice(TOOL_PART_TYPE_PREFIX.length);
}
return part.type;
};
@@ -0,0 +1,11 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type AdminChatThreadMessagePart } from '@/settings/admin-panel/types/AdminChatThreadMessagePart';
export const isRenderableAdminChatMessagePart = (
part: AdminChatThreadMessagePart,
): boolean =>
(part.type === 'text' && isNonEmptyString(part.textContent)) ||
(part.type === 'reasoning' && isNonEmptyString(part.reasoningContent)) ||
part.type.startsWith('tool-') ||
part.type === 'dynamic-tool';
@@ -0,0 +1,21 @@
import { isString } from '@sniptt/guards';
import { type JsonValue } from 'type-fest';
import { isDefined } from 'twenty-shared/utils';
export const parseAdminToolJson = (
value: JsonValue | null | undefined,
): JsonValue | null => {
if (!isDefined(value)) {
return null;
}
if (isString(value)) {
try {
return JSON.parse(value);
} catch {
return value;
}
}
return value;
};
@@ -1,3 +1,5 @@
import { styled } from '@linaria/react';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableHeaderText } from '@/ui/layout/table/components/TableHeaderText';
import { sortedFieldByTableFamilyState } from '@/ui/layout/table/states/sortedFieldByTableFamilyState';
@@ -6,6 +8,12 @@ import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAto
import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState';
import { IconArrowDown, IconArrowUp, type IconComponent } from 'twenty-ui/icon';
const StyledSortIconContainer = styled.span`
align-items: center;
display: flex;
flex-shrink: 0;
`;
export const SortableTableHeader = ({
tableId,
fieldName,
@@ -59,20 +67,16 @@ export const SortableTableHeader = ({
return (
<TableHeader align={align} onClick={handleClick}>
{isSortActive && align === 'right' ? (
isAsc ? (
<IconArrowUp size="14" />
) : (
<IconArrowDown size="14" />
)
<StyledSortIconContainer>
{isAsc ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
</StyledSortIconContainer>
) : null}
{Icon && <Icon size={14} />}
<TableHeaderText>{label}</TableHeaderText>
{isSortActive && align === 'left' ? (
isAsc ? (
<IconArrowUp size="14" />
) : (
<IconArrowDown size="14" />
)
<StyledSortIconContainer>
{isAsc ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
</StyledSortIconContainer>
) : null}
</TableHeader>
);
@@ -0,0 +1,96 @@
import { t } from '@lingui/core/macro';
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { IconDotsVertical } from 'twenty-ui/icon';
import { Button, SearchInput } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { H2Title } from 'twenty-ui/typography';
import { AI_ADMIN_PATH } from '@/settings/admin-panel/ai/constants/AiAdminPath';
import { SettingsAdminChatsContent } from '@/settings/admin-panel/chats/components/SettingsAdminChatsContent';
import { SettingsAdminChatsFilterDropdown } from '@/settings/admin-panel/chats/components/SettingsAdminChatsFilterDropdown';
import { useAdminChatThreads } from '@/settings/admin-panel/chats/hooks/useAdminChatThreads';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout';
const StyledShowMoreContainer = styled.div`
display: flex;
justify-content: center;
margin-top: ${themeCssVariables.spacing[2]};
`;
export const SettingsAdminChats = () => {
const {
searchQuery,
setSearchQuery,
filters,
setFilters,
threads,
totalCount,
hasMore,
loading,
isShowMoreDisabled,
error,
handleShowMore,
} = useAdminChatThreads();
return (
<SettingsPageLayout
links={[
{
children: t`Other`,
href: getSettingsPath(SettingsPath.AdminPanel),
},
{
children: t`Admin Panel - AI`,
href: AI_ADMIN_PATH,
},
{
children: t`Chats`,
},
]}
>
<SettingsPageContainer>
<Section>
<H2Title
title={t`Chats`}
description={t`Browse AI chat threads across all workspaces (${totalCount} matching)`}
/>
<SearchInput
placeholder={t`Search by workspace, user email or thread id...`}
value={searchQuery}
onChange={setSearchQuery}
filterDropdown={(filterButton: ReactNode) => (
<SettingsAdminChatsFilterDropdown
filterButton={filterButton}
filters={filters}
onFiltersChange={setFilters}
/>
)}
/>
<SettingsAdminChatsContent
threads={threads}
loading={loading}
error={error}
/>
{hasMore && !isDefined(error) && (
<StyledShowMoreContainer>
<Button
title={t`Show more`}
Icon={IconDotsVertical}
onClick={handleShowMore}
disabled={isShowMoreDisabled}
size="small"
variant="secondary"
/>
</StyledShowMoreContainer>
)}
</Section>
</SettingsPageContainer>
</SettingsPageLayout>
);
};
@@ -415,9 +415,7 @@ export const SettingsAdminWorkspaceDetail = () => {
<TableCell color={themeCssVariables.font.color.primary}>
{thread.title || t`Untitled`}
</TableCell>
<TableCell align="right">
{thread.conversationSize}
</TableCell>
<TableCell align="right">{thread.messageCount}</TableCell>
<TableCell align="right">
{new Date(thread.updatedAt).toLocaleDateString()}
</TableCell>
@@ -0,0 +1,219 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AdminPanelChatService } from 'src/engine/core-modules/admin-panel/services/admin-panel-chat.service';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import {
AgentMessageEntity,
AgentMessageRole,
} from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util';
const createMessageQueryBuilderMock = (getRawManyResult: () => unknown[]) => {
const queryBuilderMock = {
select: jest.fn(),
addSelect: jest.fn(),
where: jest.fn(),
groupBy: jest.fn(),
getRawMany: jest
.fn()
.mockImplementation(() => Promise.resolve(getRawManyResult())),
};
for (const method of ['select', 'addSelect', 'where', 'groupBy'] as const) {
queryBuilderMock[method].mockReturnValue(queryBuilderMock);
}
return queryBuilderMock;
};
describe('AdminPanelChatService', () => {
let service: AdminPanelChatService;
let workspaceRepositoryFindOneMock: jest.Mock;
let threadRepositoryFindMock: jest.Mock;
let threadRepositoryFindOneMock: jest.Mock;
let messageRepositoryFindMock: jest.Mock;
let messageRawManyResult: unknown[];
beforeEach(async () => {
workspaceRepositoryFindOneMock = jest.fn();
threadRepositoryFindMock = jest.fn();
threadRepositoryFindOneMock = jest.fn();
messageRepositoryFindMock = jest.fn();
messageRawManyResult = [];
const messageQueryBuilderMock = createMessageQueryBuilderMock(
() => messageRawManyResult,
);
const module: TestingModule = await Test.createTestingModule({
providers: [
AdminPanelChatService,
{
provide: getRepositoryToken(WorkspaceEntity),
useValue: { findOne: workspaceRepositoryFindOneMock },
},
{
provide: getRepositoryToken(AgentChatThreadEntity),
useValue: {
find: threadRepositoryFindMock,
findOne: threadRepositoryFindOneMock,
},
},
{
provide: getWorkspaceScopedRepositoryToken(AgentMessageEntity),
useValue: {
find: messageRepositoryFindMock,
createQueryBuilder: jest.fn(() => messageQueryBuilderMock),
},
},
],
}).compile();
service = module.get<AdminPanelChatService>(AdminPanelChatService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('getWorkspaceChatThreads', () => {
it('should merge message counts and default missing threads to zero', async () => {
workspaceRepositoryFindOneMock.mockResolvedValue({
id: 'workspace-1',
allowImpersonation: true,
});
threadRepositoryFindMock.mockResolvedValue([
{ id: 'thread-1', title: 'A', conversationSize: 1000 },
{ id: 'thread-2', title: 'B', conversationSize: 2000 },
]);
messageRawManyResult = [{ threadId: 'thread-1', messageCount: 7 }];
const result = await service.getWorkspaceChatThreads('workspace-1');
expect(result[0].messageCount).toBe(7);
expect(result[1].messageCount).toBe(0);
});
it('should throw when the workspace has not enabled support access', async () => {
workspaceRepositoryFindOneMock.mockResolvedValue({
id: 'workspace-1',
allowImpersonation: false,
});
await expect(
service.getWorkspaceChatThreads('workspace-1'),
).rejects.toThrow(UserInputError);
});
});
describe('getChatThreadMessages', () => {
it('should return the hidden kickoff message with enriched parts', async () => {
threadRepositoryFindOneMock.mockResolvedValue({
id: 'thread-1',
workspaceId: 'workspace-1',
title: 'Workspace setup',
totalInputTokens: 10,
totalOutputTokens: 20,
conversationSize: 1000,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-02'),
});
workspaceRepositoryFindOneMock.mockResolvedValue({
id: 'workspace-1',
allowImpersonation: true,
});
messageRepositoryFindMock.mockResolvedValue([
{
id: 'message-1',
role: AgentMessageRole.USER,
isHidden: true,
createdAt: new Date('2026-01-01'),
parts: [
{
type: 'text',
orderIndex: 0,
textContent: 'kickoff prompt',
reasoningContent: null,
toolName: null,
toolCallId: null,
toolInput: null,
toolOutput: null,
state: null,
errorMessage: null,
},
],
},
{
id: 'message-2',
role: AgentMessageRole.ASSISTANT,
isHidden: false,
createdAt: new Date('2026-01-01T01:00:00Z'),
parts: [
{
type: 'tool-call',
orderIndex: 1,
textContent: null,
reasoningContent: null,
toolName: 'create_many_object_metadata',
toolCallId: 'call-1',
toolInput: { objects: [] },
toolOutput: { success: true },
state: 'output-available',
errorMessage: null,
},
{
type: 'reasoning',
orderIndex: 0,
textContent: null,
reasoningContent: 'thinking',
toolName: null,
toolCallId: null,
toolInput: null,
toolOutput: null,
state: null,
errorMessage: null,
},
],
},
]);
const result = await service.getChatThreadMessages('thread-1');
expect(messageRepositoryFindMock).toHaveBeenCalledWith(
'workspace-1',
expect.objectContaining({ where: { threadId: 'thread-1' } }),
);
expect(result.thread.messageCount).toBe(1);
expect(result.messages[0].isHidden).toBe(true);
expect(result.messages[1].parts.map((part) => part.orderIndex)).toEqual([
0, 1,
]);
expect(result.messages[1].parts[1]).toEqual(
expect.objectContaining({
toolName: 'create_many_object_metadata',
toolInput: { objects: [] },
toolOutput: { success: true },
state: 'output-available',
}),
);
});
it('should throw when the thread workspace has not enabled support access', async () => {
threadRepositoryFindOneMock.mockResolvedValue({
id: 'thread-1',
workspaceId: 'workspace-1',
});
workspaceRepositoryFindOneMock.mockResolvedValue({
id: 'workspace-1',
allowImpersonation: false,
});
await expect(service.getChatThreadMessages('thread-1')).rejects.toThrow(
UserInputError,
);
});
});
});
@@ -0,0 +1,499 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { isString } from '@sniptt/guards';
import { ASK_QUESTIONS_TOOL_NAME } from 'twenty-shared/ai';
import { Brackets } from 'typeorm';
import { AdminChatThreadScope } from 'src/engine/core-modules/admin-panel/enums/admin-chat-thread-scope.enum';
import { AdminChatThreadSortDirection } from 'src/engine/core-modules/admin-panel/enums/admin-chat-thread-sort-direction.enum';
import { AdminChatThreadSortField } from 'src/engine/core-modules/admin-panel/enums/admin-chat-thread-sort-field.enum';
import { AdminPanelGlobalChatThreadsService } from 'src/engine/core-modules/admin-panel/services/admin-panel-global-chat-threads.service';
import {
AgentMessageEntity,
AgentMessageRole,
} from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity';
import { WORKSPACE_SETUP_CHAT_THREAD_ID_NAMESPACE } from 'src/engine/metadata-modules/ai/ai-chat/constants/workspace-setup-chat-thread-id-namespace.constant';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
const QUERY_BUILDER_CHAINABLE_METHODS = [
'innerJoin',
'leftJoin',
'withDeleted',
'setParameter',
'where',
'andWhere',
'select',
'addSelect',
'groupBy',
'addGroupBy',
'orderBy',
'addOrderBy',
'limit',
'offset',
] as const;
const SUB_QUERY_BUILDER_CHAINABLE_METHODS = [
'select',
'from',
'innerJoin',
'where',
'andWhere',
] as const;
type SubQueryBuilderMock = Record<
(typeof SUB_QUERY_BUILDER_CHAINABLE_METHODS)[number],
jest.Mock
> & {
getQuery: jest.Mock;
};
const createSubQueryBuilderMock = (): SubQueryBuilderMock => {
const subQueryBuilderMock = {} as SubQueryBuilderMock;
for (const method of SUB_QUERY_BUILDER_CHAINABLE_METHODS) {
subQueryBuilderMock[method] = jest
.fn()
.mockReturnValue(subQueryBuilderMock);
}
subQueryBuilderMock.getQuery = jest.fn().mockReturnValue('SUBQUERY');
return subQueryBuilderMock;
};
type QueryBuilderMock = Record<
(typeof QUERY_BUILDER_CHAINABLE_METHODS)[number],
jest.Mock
> & {
subQuery: jest.Mock;
getRawMany: jest.Mock;
getCount: jest.Mock;
};
const createQueryBuilderMock = (
getRawManyResult: () => unknown[],
getCountResult: () => number,
onSubQueryCreated?: (subQueryBuilderMock: SubQueryBuilderMock) => void,
): QueryBuilderMock => {
const queryBuilderMock = {} as QueryBuilderMock;
for (const method of QUERY_BUILDER_CHAINABLE_METHODS) {
queryBuilderMock[method] = jest.fn().mockReturnValue(queryBuilderMock);
}
queryBuilderMock.subQuery = jest.fn().mockImplementation(() => {
const subQueryBuilderMock = createSubQueryBuilderMock();
onSubQueryCreated?.(subQueryBuilderMock);
return subQueryBuilderMock;
});
queryBuilderMock.getRawMany = jest
.fn()
.mockImplementation(() => Promise.resolve(getRawManyResult()));
queryBuilderMock.getCount = jest
.fn()
.mockImplementation(() => Promise.resolve(getCountResult()));
return queryBuilderMock;
};
const findSubQueryBuilderByAlias = (
subQueryBuilderMocks: SubQueryBuilderMock[],
alias: string,
): SubQueryBuilderMock | undefined =>
subQueryBuilderMocks.find((subQueryBuilderMock) =>
subQueryBuilderMock.from.mock.calls.some(
([, fromAlias]) => fromAlias === alias,
),
);
const getAndWhereConditions = (queryBuilderMock: QueryBuilderMock): string[] =>
queryBuilderMock.andWhere.mock.calls
.map(([condition]) => condition)
.filter((condition): condition is string => isString(condition));
const RAW_ROW = {
id: 'thread-1',
title: 'Workspace setup',
workspaceId: 'workspace-1',
workspaceDisplayName: 'Acme',
userWorkspaceId: 'user-workspace-1',
userEmail: 'jane@acme.com',
userFirstName: 'Jane',
userLastName: 'Doe',
messageCount: 4,
userReplyCount: 2,
hasError: false,
isOnboardingThread: true,
deletedAt: null,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-02'),
};
const DEFAULT_ARGS = {
scope: AdminChatThreadScope.ONBOARDING,
hasErrorOnly: false,
userNeverEngagedOnly: false,
sortBy: AdminChatThreadSortField.CREATED_AT,
sortDirection: AdminChatThreadSortDirection.DESC,
limit: 25,
offset: 0,
};
describe('AdminPanelGlobalChatThreadsService', () => {
let service: AdminPanelGlobalChatThreadsService;
let threadQueryBuilderMocks: QueryBuilderMock[];
let subQueryBuilderMocks: SubQueryBuilderMock[];
let threadRawManyResult: unknown[];
let threadCountResult: number;
beforeEach(async () => {
threadQueryBuilderMocks = [];
subQueryBuilderMocks = [];
threadRawManyResult = [];
threadCountResult = 0;
const module: TestingModule = await Test.createTestingModule({
providers: [
AdminPanelGlobalChatThreadsService,
{
provide: getRepositoryToken(AgentChatThreadEntity),
useValue: {
createQueryBuilder: jest.fn(() => {
const queryBuilderMock = createQueryBuilderMock(
() => threadRawManyResult,
() => threadCountResult,
(subQueryBuilderMock) =>
subQueryBuilderMocks.push(subQueryBuilderMock),
);
threadQueryBuilderMocks.push(queryBuilderMock);
return queryBuilderMock;
}),
},
},
],
}).compile();
service = module.get<AdminPanelGlobalChatThreadsService>(
AdminPanelGlobalChatThreadsService,
);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('getGlobalChatThreads', () => {
it('should apply the onboarding fingerprint predicate only for the ONBOARDING scope', async () => {
await service.getGlobalChatThreads(DEFAULT_ARGS);
const [listQueryBuilder, countQueryBuilder] = threadQueryBuilderMocks;
for (const queryBuilder of [listQueryBuilder, countQueryBuilder]) {
const conditions = getAndWhereConditions(queryBuilder);
expect(
conditions.some(
(condition) =>
condition.includes('uuid_generate_v5') &&
condition.includes('EXISTS (SUBQUERY)'),
),
).toBe(true);
expect(queryBuilder.setParameter).toHaveBeenCalledWith(
'setupThreadNamespace',
WORKSPACE_SETUP_CHAT_THREAD_ID_NAMESPACE,
);
}
const hiddenKickoffSubQueryBuilder = subQueryBuilderMocks[0];
expect(hiddenKickoffSubQueryBuilder.from).toHaveBeenCalledWith(
AgentMessageEntity,
'hiddenMessage',
);
expect(hiddenKickoffSubQueryBuilder.andWhere).toHaveBeenCalledWith(
'hiddenMessage.isHidden = true',
);
});
it('should not apply the onboarding fingerprint predicate for the ALL scope', async () => {
await service.getGlobalChatThreads({
...DEFAULT_ARGS,
scope: AdminChatThreadScope.ALL,
});
for (const queryBuilder of threadQueryBuilderMocks) {
expect(
getAndWhereConditions(queryBuilder).some((condition) =>
condition.includes('uuid_generate_v5'),
),
).toBe(false);
}
});
it('should gate both queries on live workspaces allowing impersonation', async () => {
await service.getGlobalChatThreads(DEFAULT_ARGS);
for (const queryBuilder of threadQueryBuilderMocks) {
expect(queryBuilder.innerJoin).toHaveBeenCalledWith(
'thread.workspace',
'workspace',
'"workspace"."allowImpersonation" = true AND "workspace"."deletedAt" IS NULL',
);
}
});
it('should keep soft-deleted user identity while joining thread owners', async () => {
await service.getGlobalChatThreads(DEFAULT_ARGS);
for (const queryBuilder of threadQueryBuilderMocks) {
expect(queryBuilder.leftJoin).toHaveBeenCalledWith(
'thread.userWorkspace',
'userWorkspace',
);
expect(queryBuilder.leftJoin).toHaveBeenCalledWith(
'userWorkspace.user',
'user',
);
expect(queryBuilder.withDeleted).toHaveBeenCalled();
}
});
it('should count only visible messages', async () => {
await service.getGlobalChatThreads(DEFAULT_ARGS);
const [listQueryBuilder] = threadQueryBuilderMocks;
expect(listQueryBuilder.leftJoin).toHaveBeenCalledWith(
'thread.messages',
'message',
'"message"."isHidden" = false',
);
});
it('should apply error and engagement filters when requested', async () => {
await service.getGlobalChatThreads({
...DEFAULT_ARGS,
hasErrorOnly: true,
userNeverEngagedOnly: true,
});
const conditions = getAndWhereConditions(threadQueryBuilderMocks[0]);
expect(
conditions.some((condition) =>
condition.includes('"lastStreamError" IS NOT NULL'),
),
).toBe(true);
expect(
conditions.some((condition) =>
condition.includes('NOT EXISTS (SUBQUERY)'),
),
).toBe(true);
expect(threadQueryBuilderMocks[0].setParameter).toHaveBeenCalledWith(
'userMessageRole',
AgentMessageRole.USER,
);
const visibleUserMessageSubQueryBuilder = findSubQueryBuilderByAlias(
subQueryBuilderMocks,
'userMessage',
);
expect(visibleUserMessageSubQueryBuilder?.from).toHaveBeenCalledWith(
AgentMessageEntity,
'userMessage',
);
expect(visibleUserMessageSubQueryBuilder?.andWhere).toHaveBeenCalledWith(
'userMessage.role = :userMessageRole',
);
});
it('should treat an answered question card as engagement', async () => {
await service.getGlobalChatThreads({
...DEFAULT_ARGS,
userNeverEngagedOnly: true,
});
for (const queryBuilder of threadQueryBuilderMocks) {
expect(
getAndWhereConditions(queryBuilder).some(
(condition) =>
condition === '(NOT EXISTS (SUBQUERY) AND NOT EXISTS (SUBQUERY))',
),
).toBe(true);
}
const answeredQuestionSubQueryBuilder = findSubQueryBuilderByAlias(
subQueryBuilderMocks,
'answeredQuestionPart',
);
expect(answeredQuestionSubQueryBuilder?.innerJoin).toHaveBeenCalledWith(
AgentMessageEntity,
'questionMessage',
'questionMessage.id = answeredQuestionPart.messageId',
);
expect(answeredQuestionSubQueryBuilder?.andWhere).toHaveBeenCalledWith(
'answeredQuestionPart.toolName = :askQuestionsToolName',
);
expect(answeredQuestionSubQueryBuilder?.andWhere).toHaveBeenCalledWith(
expect.stringContaining(`-> 'result' ->> 'status'`),
);
});
it('should count answered question cards as user replies', async () => {
await service.getGlobalChatThreads(DEFAULT_ARGS);
const [listQueryBuilder] = threadQueryBuilderMocks;
const userReplyCountSelect = listQueryBuilder.addSelect.mock.calls.find(
([, alias]) => alias === 'userReplyCount',
)?.[0];
expect(userReplyCountSelect).toContain(
'FILTER (WHERE "message"."role" = :userMessageRole)',
);
expect(userReplyCountSelect).toContain('+ (SUBQUERY)');
expect(listQueryBuilder.setParameter).toHaveBeenCalledWith(
'askQuestionsToolName',
ASK_QUESTIONS_TOOL_NAME,
);
expect(listQueryBuilder.setParameter).toHaveBeenCalledWith(
'answeredQuestionStatus',
'answered',
);
});
it('should apply the trimmed search term as an ILIKE pattern', async () => {
await service.getGlobalChatThreads({
...DEFAULT_ARGS,
searchTerm: ' jane@acme.com ',
});
expect(threadQueryBuilderMocks[0].andWhere).toHaveBeenCalledWith(
expect.any(Brackets),
{ searchPattern: '%jane@acme.com%' },
);
});
it('should escape LIKE wildcards in the search term', async () => {
await service.getGlobalChatThreads({
...DEFAULT_ARGS,
searchTerm: 'my_workspace 100%',
});
expect(threadQueryBuilderMocks[0].andWhere).toHaveBeenCalledWith(
expect.any(Brackets),
{ searchPattern: '%my\\_workspace 100\\%%' },
);
});
it('should sort by message count with a stable id tiebreaker', async () => {
await service.getGlobalChatThreads({
...DEFAULT_ARGS,
sortBy: AdminChatThreadSortField.MESSAGE_COUNT,
sortDirection: AdminChatThreadSortDirection.ASC,
});
const [listQueryBuilder] = threadQueryBuilderMocks;
expect(listQueryBuilder.orderBy).toHaveBeenCalledWith(
'"messageCount"',
'ASC',
);
expect(listQueryBuilder.addOrderBy).toHaveBeenCalledWith(
'"thread"."id"',
'ASC',
);
});
it('should sort by the reply count alias', async () => {
await service.getGlobalChatThreads({
...DEFAULT_ARGS,
sortBy: AdminChatThreadSortField.REPLY_COUNT,
sortDirection: AdminChatThreadSortDirection.DESC,
});
expect(threadQueryBuilderMocks[0].orderBy).toHaveBeenCalledWith(
'"userReplyCount"',
'DESC',
);
});
it('should sort by thread columns for date sort fields', async () => {
await service.getGlobalChatThreads(DEFAULT_ARGS);
expect(threadQueryBuilderMocks[0].orderBy).toHaveBeenCalledWith(
'"thread"."createdAt"',
'DESC',
);
});
it('should clamp the limit and floor the offset', async () => {
await service.getGlobalChatThreads({
...DEFAULT_ARGS,
limit: 500,
offset: -10,
});
const [listQueryBuilder] = threadQueryBuilderMocks;
expect(listQueryBuilder.limit).toHaveBeenCalledWith(100);
expect(listQueryBuilder.offset).toHaveBeenCalledWith(0);
});
it('should compute hasMore from the offset, page size and total count', async () => {
threadRawManyResult = Array.from({ length: 25 }, (_, index) => ({
...RAW_ROW,
id: `thread-${index}`,
}));
threadCountResult = 60;
const result = await service.getGlobalChatThreads({
...DEFAULT_ARGS,
offset: 25,
});
expect(result.totalCount).toBe(60);
expect(result.hasMore).toBe(true);
const lastPageResult = await service.getGlobalChatThreads({
...DEFAULT_ARGS,
offset: 35,
});
expect(lastPageResult.hasMore).toBe(false);
});
it('should map raw rows to DTOs tolerating missing user identity', async () => {
threadRawManyResult = [
{
...RAW_ROW,
userEmail: null,
userFirstName: null,
userLastName: null,
hasError: true,
},
];
threadCountResult = 1;
const result = await service.getGlobalChatThreads(DEFAULT_ARGS);
expect(result.threads).toEqual([
expect.objectContaining({
id: 'thread-1',
userEmail: null,
hasError: true,
isOnboardingThread: true,
messageCount: 4,
userReplyCount: 2,
}),
]);
expect(result.hasMore).toBe(false);
});
});
});
@@ -15,6 +15,7 @@ import { WorkerHealthIndicator } from 'src/engine/core-modules/admin-panel/indic
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
import { AdminPanelBillingService } from 'src/engine/core-modules/admin-panel/services/admin-panel-billing.service';
import { AdminPanelChatService } from 'src/engine/core-modules/admin-panel/services/admin-panel-chat.service';
import { AdminPanelGlobalChatThreadsService } from 'src/engine/core-modules/admin-panel/services/admin-panel-global-chat-threads.service';
import { AdminPanelConfigService } from 'src/engine/core-modules/admin-panel/services/admin-panel-config.service';
import { AdminPanelServerAdminService } from 'src/engine/core-modules/admin-panel/services/admin-panel-server-admin.service';
import { AdminPanelSigningKeyService } from 'src/engine/core-modules/admin-panel/services/admin-panel-signing-key.service';
@@ -93,6 +94,7 @@ import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspac
AdminPanelStatisticsService,
AdminPanelBillingService,
AdminPanelChatService,
AdminPanelGlobalChatThreadsService,
AdminPanelConfigService,
AdminPanelSigningKeyService,
AdminPanelVersionService,
@@ -112,6 +114,7 @@ import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspac
AdminPanelUserLookupService,
AdminPanelStatisticsService,
AdminPanelChatService,
AdminPanelGlobalChatThreadsService,
AdminPanelConfigService,
AdminPanelVersionService,
MaintenanceModeService,
@@ -17,6 +17,7 @@ import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/adm
import { AdminPanelQueueService } from 'src/engine/core-modules/admin-panel/admin-panel-queue.service';
import { AdminChatThreadMessagesDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-chat-thread-messages.dto';
import { AdminPanelRecentUserDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-recent-user.dto';
import { PaginatedAdminChatThreadsDTO } from 'src/engine/core-modules/admin-panel/dtos/paginated-admin-chat-threads.dto';
import { AdminPanelTopWorkspaceDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-top-workspace.dto';
import { AdminPanelWorkspaceBillingDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-workspace-billing.dto';
import { AdminWorkspaceChatThreadDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-workspace-chat-thread.dto';
@@ -35,12 +36,16 @@ import { UpdateWorkspaceFeatureFlagInput } from 'src/engine/core-modules/admin-p
import { UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.dto';
import { UserLookupInput } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.input';
import { VersionInfoDTO } from 'src/engine/core-modules/admin-panel/dtos/version-info.dto';
import { AdminChatThreadScope } from 'src/engine/core-modules/admin-panel/enums/admin-chat-thread-scope.enum';
import { AdminChatThreadSortDirection } from 'src/engine/core-modules/admin-panel/enums/admin-chat-thread-sort-direction.enum';
import { AdminChatThreadSortField } from 'src/engine/core-modules/admin-panel/enums/admin-chat-thread-sort-field.enum';
import { HealthIndicatorId } from 'src/engine/core-modules/admin-panel/enums/health-indicator-id.enum';
import { JobStateEnum } from 'src/engine/core-modules/admin-panel/enums/job-state.enum';
import { QueueMetricsTimeRange } from 'src/engine/core-modules/admin-panel/enums/queue-metrics-time-range.enum';
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
import { AdminPanelBillingService } from 'src/engine/core-modules/admin-panel/services/admin-panel-billing.service';
import { AdminPanelChatService } from 'src/engine/core-modules/admin-panel/services/admin-panel-chat.service';
import { AdminPanelGlobalChatThreadsService } from 'src/engine/core-modules/admin-panel/services/admin-panel-global-chat-threads.service';
import { AdminPanelConfigService } from 'src/engine/core-modules/admin-panel/services/admin-panel-config.service';
import { AdminPanelSigningKeyService } from 'src/engine/core-modules/admin-panel/services/admin-panel-signing-key.service';
import { AdminPanelServerAdminService } from 'src/engine/core-modules/admin-panel/services/admin-panel-server-admin.service';
@@ -124,6 +129,7 @@ export class AdminPanelResolver {
private readonly adminStatisticsService: AdminPanelStatisticsService,
private readonly adminBillingService: AdminPanelBillingService,
private readonly adminChatService: AdminPanelChatService,
private readonly adminGlobalChatThreadsService: AdminPanelGlobalChatThreadsService,
private readonly adminConfigService: AdminPanelConfigService,
private readonly adminVersionService: AdminPanelVersionService,
private readonly adminPanelHealthService: AdminPanelHealthService,
@@ -797,6 +803,58 @@ export class AdminPanelResolver {
return this.adminChatService.getChatThreadMessages(threadId);
}
@UseGuards(ServerLevelImpersonateGuard)
@Query(() => PaginatedAdminChatThreadsDTO)
async getAdminChatThreads(
@Args('scope', {
type: () => AdminChatThreadScope,
nullable: true,
defaultValue: AdminChatThreadScope.ALL,
})
scope: AdminChatThreadScope | null,
@Args('hasErrorOnly', {
type: () => Boolean,
nullable: true,
defaultValue: false,
})
hasErrorOnly: boolean | null,
@Args('userNeverEngagedOnly', {
type: () => Boolean,
nullable: true,
defaultValue: false,
})
userNeverEngagedOnly: boolean | null,
@Args('sortBy', {
type: () => AdminChatThreadSortField,
nullable: true,
defaultValue: AdminChatThreadSortField.CREATED_AT,
})
sortBy: AdminChatThreadSortField | null,
@Args('sortDirection', {
type: () => AdminChatThreadSortDirection,
nullable: true,
defaultValue: AdminChatThreadSortDirection.DESC,
})
sortDirection: AdminChatThreadSortDirection | null,
@Args('limit', { type: () => Int, nullable: true, defaultValue: 25 })
limit: number | null,
@Args('offset', { type: () => Int, nullable: true, defaultValue: 0 })
offset: number | null,
@Args('searchTerm', { type: () => String, nullable: true })
searchTerm?: string | null,
): Promise<PaginatedAdminChatThreadsDTO> {
return this.adminGlobalChatThreadsService.getGlobalChatThreads({
scope: scope ?? AdminChatThreadScope.ALL,
hasErrorOnly: hasErrorOnly ?? false,
userNeverEngagedOnly: userNeverEngagedOnly ?? false,
searchTerm: searchTerm ?? undefined,
sortBy: sortBy ?? AdminChatThreadSortField.CREATED_AT,
sortDirection: sortDirection ?? AdminChatThreadSortDirection.DESC,
limit: limit ?? 25,
offset: offset ?? 0,
});
}
@UseGuards(AdminPanelGuard)
@Query(() => ApplicationRegistrationEntity)
async findOneAdminApplicationRegistration(
@@ -0,0 +1 @@
export const ADMIN_CHAT_THREADS_MAX_PAGE_SIZE = 100;
@@ -1,13 +1,36 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Field, Int, ObjectType } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
@ObjectType('AdminChatMessagePart')
export class AdminChatMessagePartDTO {
@Field(() => String)
type: string;
@Field(() => Int)
orderIndex: number;
@Field(() => String, { nullable: true })
textContent: string | null;
@Field(() => String, { nullable: true })
reasoningContent: string | null;
@Field(() => String, { nullable: true })
toolName: string | null;
@Field(() => String, { nullable: true })
toolCallId: string | null;
@Field(() => GraphQLJSON, { nullable: true })
toolInput: unknown | null;
@Field(() => GraphQLJSON, { nullable: true })
toolOutput: unknown | null;
@Field(() => String, { nullable: true })
state: string | null;
@Field(() => String, { nullable: true })
errorMessage: string | null;
}
@@ -15,6 +15,9 @@ export class AdminChatMessageDTO {
@Field(() => AgentMessageRole)
role: AgentMessageRole;
@Field(() => Boolean)
isHidden: boolean;
@Field(() => [AdminChatMessagePartDTO])
parts: AdminChatMessagePartDTO[];
@@ -0,0 +1,51 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('AdminChatThreadListItem')
export class AdminChatThreadListItemDTO {
@Field(() => UUIDScalarType)
id: string;
@Field(() => String, { nullable: true })
title: string | null;
@Field(() => UUIDScalarType)
workspaceId: string;
@Field(() => String, { nullable: true })
workspaceDisplayName: string | null;
@Field(() => UUIDScalarType)
userWorkspaceId: string;
@Field(() => String, { nullable: true })
userEmail: string | null;
@Field(() => String, { nullable: true })
userFirstName: string | null;
@Field(() => String, { nullable: true })
userLastName: string | null;
@Field(() => Int)
messageCount: number;
@Field(() => Int)
userReplyCount: number;
@Field(() => Boolean)
hasError: boolean;
@Field(() => Boolean)
isOnboardingThread: boolean;
@Field(() => Date, { nullable: true })
deletedAt: Date | null;
@Field(() => Date)
createdAt: Date;
@Field(() => Date)
updatedAt: Date;
}
@@ -19,6 +19,9 @@ export class AdminWorkspaceChatThreadDTO {
@Field(() => Int)
conversationSize: number;
@Field(() => Int)
messageCount: number;
@Field(() => Date)
createdAt: Date;
@@ -0,0 +1,15 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { AdminChatThreadListItemDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-chat-thread-list-item.dto';
@ObjectType('PaginatedAdminChatThreads')
export class PaginatedAdminChatThreadsDTO {
@Field(() => [AdminChatThreadListItemDTO])
threads: AdminChatThreadListItemDTO[];
@Field(() => Int)
totalCount: number;
@Field(() => Boolean)
hasMore: boolean;
}
@@ -0,0 +1,11 @@
import { registerEnumType } from '@nestjs/graphql';
export enum AdminChatThreadScope {
ONBOARDING = 'ONBOARDING',
ALL = 'ALL',
}
registerEnumType(AdminChatThreadScope, {
name: 'AdminChatThreadScope',
description: 'Scope of chat threads to list in the admin panel',
});
@@ -0,0 +1,11 @@
import { registerEnumType } from '@nestjs/graphql';
export enum AdminChatThreadSortDirection {
ASC = 'ASC',
DESC = 'DESC',
}
registerEnumType(AdminChatThreadSortDirection, {
name: 'AdminChatThreadSortDirection',
description: 'Direction to sort admin chat threads',
});
@@ -0,0 +1,13 @@
import { registerEnumType } from '@nestjs/graphql';
export enum AdminChatThreadSortField {
MESSAGE_COUNT = 'MESSAGE_COUNT',
REPLY_COUNT = 'REPLY_COUNT',
CREATED_AT = 'CREATED_AT',
UPDATED_AT = 'UPDATED_AT',
}
registerEnumType(AdminChatThreadSortField, {
name: 'AdminChatThreadSortField',
description: 'Field to sort admin chat threads by',
});
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { type AdminChatMessageDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-chat-message.dto';
@@ -11,6 +12,7 @@ import { AgentMessageEntity } from 'src/engine/metadata-modules/ai/ai-agent-exec
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
@Injectable()
export class AdminPanelChatService {
constructor(
@@ -33,7 +35,7 @@ export class AdminPanelChatService {
select: { id: true, allowImpersonation: true },
});
if (!workspace) {
if (!isDefined(workspace)) {
throw new UserInputError('Workspace not found');
}
@@ -53,17 +55,48 @@ export class AdminPanelChatService {
take: 100,
});
const messageCountByThreadId = await this.getMessageCountByThreadId({
workspaceId,
threadIds: threads.map((thread) => thread.id),
});
return threads.map((thread) => ({
id: thread.id,
title: thread.title,
totalInputTokens: thread.totalInputTokens,
totalOutputTokens: thread.totalOutputTokens,
conversationSize: thread.conversationSize,
messageCount: messageCountByThreadId.get(thread.id) ?? 0,
createdAt: thread.createdAt,
updatedAt: thread.updatedAt,
}));
}
private async getMessageCountByThreadId({
workspaceId,
threadIds,
}: {
workspaceId: string;
threadIds: string[];
}): Promise<Map<string, number>> {
if (!isNonEmptyArray(threadIds)) {
return new Map();
}
const rows = await this.agentMessageRepository
.createQueryBuilder('message')
.select('"message"."threadId"', 'threadId')
.addSelect('COUNT(*)::int', 'messageCount')
.where(
'"message"."workspaceId" = :workspaceId AND "message"."threadId" IN (:...threadIds) AND "message"."isHidden" = false',
{ workspaceId, threadIds },
)
.groupBy('"message"."threadId"')
.getRawMany<{ threadId: string; messageCount: number }>();
return new Map(rows.map((row) => [row.threadId, row.messageCount]));
}
async getChatThreadMessages(threadId: string): Promise<{
thread: AdminWorkspaceChatThreadDTO;
messages: AdminChatMessageDTO[];
@@ -72,7 +105,7 @@ export class AdminPanelChatService {
where: { id: threadId },
});
if (!thread) {
if (!isDefined(thread)) {
throw new UserInputError('Thread not found');
}
@@ -81,7 +114,7 @@ export class AdminPanelChatService {
const messages = await this.agentMessageRepository.find(
thread.workspaceId,
{
where: { threadId, isHidden: false },
where: { threadId },
relations: { parts: true },
order: { createdAt: 'ASC' },
},
@@ -94,18 +127,27 @@ export class AdminPanelChatService {
totalInputTokens: thread.totalInputTokens,
totalOutputTokens: thread.totalOutputTokens,
conversationSize: thread.conversationSize,
messageCount: messages.filter((message) => !message.isHidden).length,
createdAt: thread.createdAt,
updatedAt: thread.updatedAt,
},
messages: messages.map((message) => ({
id: message.id,
role: message.role,
isHidden: message.isHidden,
parts: (message.parts ?? [])
.sort((a, b) => a.orderIndex - b.orderIndex)
.map((part) => ({
type: part.type,
orderIndex: part.orderIndex,
textContent: part.textContent,
reasoningContent: part.reasoningContent,
toolName: part.toolName,
toolCallId: part.toolCallId,
toolInput: part.toolInput,
toolOutput: part.toolOutput,
state: part.state,
errorMessage: part.errorMessage,
})),
createdAt: message.createdAt,
})),
@@ -0,0 +1,291 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import {
ASK_QUESTIONS_TOOL_NAME,
type AskQuestionsToolStatus,
} from 'twenty-shared/ai';
import { isNonEmptyString } from '@sniptt/guards';
import { Brackets, Repository, type SelectQueryBuilder } from 'typeorm';
import { ADMIN_CHAT_THREADS_MAX_PAGE_SIZE } from 'src/engine/core-modules/admin-panel/constants/admin-chat-threads-max-page-size.constant';
import { type AdminChatThreadListItemDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-chat-thread-list-item.dto';
import { type PaginatedAdminChatThreadsDTO } from 'src/engine/core-modules/admin-panel/dtos/paginated-admin-chat-threads.dto';
import { AdminChatThreadScope } from 'src/engine/core-modules/admin-panel/enums/admin-chat-thread-scope.enum';
import { AdminChatThreadSortDirection } from 'src/engine/core-modules/admin-panel/enums/admin-chat-thread-sort-direction.enum';
import { AdminChatThreadSortField } from 'src/engine/core-modules/admin-panel/enums/admin-chat-thread-sort-field.enum';
import { AgentMessagePartEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message-part.entity';
import {
AgentMessageEntity,
AgentMessageRole,
} from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity';
import { WORKSPACE_SETUP_CHAT_THREAD_ID_NAMESPACE } from 'src/engine/metadata-modules/ai/ai-chat/constants/workspace-setup-chat-thread-id-namespace.constant';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
const WORKSPACE_SETUP_THREAD_ID_EXPRESSION = `public.uuid_generate_v5(
:setupThreadNamespace::uuid,
"thread"."workspaceId"::text || ':' || "thread"."userWorkspaceId"::text
)`;
const ANSWERED_ASK_QUESTIONS_STATUS: AskQuestionsToolStatus = 'answered';
const ANSWERED_ASK_QUESTIONS_PART_EXPRESSION = `"answeredQuestionPart"."toolOutput" -> 'result' ->> 'status' = :answeredQuestionStatus`;
const ORDER_EXPRESSION_BY_SORT_FIELD: Record<AdminChatThreadSortField, string> =
{
[AdminChatThreadSortField.MESSAGE_COUNT]: '"messageCount"',
[AdminChatThreadSortField.REPLY_COUNT]: '"userReplyCount"',
[AdminChatThreadSortField.CREATED_AT]: '"thread"."createdAt"',
[AdminChatThreadSortField.UPDATED_AT]: '"thread"."updatedAt"',
};
type GlobalChatThreadsArgs = {
scope: AdminChatThreadScope;
hasErrorOnly: boolean;
userNeverEngagedOnly: boolean;
searchTerm?: string;
sortBy: AdminChatThreadSortField;
sortDirection: AdminChatThreadSortDirection;
limit: number;
offset: number;
};
type GlobalChatThreadRawRow = {
id: string;
title: string | null;
workspaceId: string;
workspaceDisplayName: string | null;
userWorkspaceId: string;
userEmail: string | null;
userFirstName: string | null;
userLastName: string | null;
messageCount: number;
userReplyCount: number;
hasError: boolean;
isOnboardingThread: boolean;
deletedAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
@Injectable()
export class AdminPanelGlobalChatThreadsService {
constructor(
// eslint-disable-next-line twenty/prefer-workspace-scoped-repository
@InjectRepository(AgentChatThreadEntity)
private readonly agentChatThreadRepository: Repository<AgentChatThreadEntity>,
) {}
private buildOnboardingThreadPredicate(
queryBuilder: SelectQueryBuilder<AgentChatThreadEntity>,
): string {
const hiddenKickoffMessageSubQuery = queryBuilder
.subQuery()
.select('1')
.from(AgentMessageEntity, 'hiddenMessage')
.where('hiddenMessage.threadId = thread.id')
.andWhere('hiddenMessage.isHidden = true')
.getQuery();
return `(EXISTS (${hiddenKickoffMessageSubQuery}) OR "thread"."id" = ${WORKSPACE_SETUP_THREAD_ID_EXPRESSION})`;
}
private buildAnsweredQuestionSubQuery(
queryBuilder: SelectQueryBuilder<AgentChatThreadEntity>,
selection: string,
): string {
return queryBuilder
.subQuery()
.select(selection)
.from(AgentMessagePartEntity, 'answeredQuestionPart')
.innerJoin(
AgentMessageEntity,
'questionMessage',
'questionMessage.id = answeredQuestionPart.messageId',
)
.where('questionMessage.threadId = thread.id')
.andWhere('questionMessage.isHidden = false')
.andWhere('answeredQuestionPart.toolName = :askQuestionsToolName')
.andWhere(ANSWERED_ASK_QUESTIONS_PART_EXPRESSION)
.getQuery();
}
private buildUserNeverEngagedPredicate(
queryBuilder: SelectQueryBuilder<AgentChatThreadEntity>,
): string {
const visibleUserMessageSubQuery = queryBuilder
.subQuery()
.select('1')
.from(AgentMessageEntity, 'userMessage')
.where('userMessage.threadId = thread.id')
.andWhere('userMessage.isHidden = false')
.andWhere('userMessage.role = :userMessageRole')
.getQuery();
const answeredQuestionSubQuery = this.buildAnsweredQuestionSubQuery(
queryBuilder,
'1',
);
return `(NOT EXISTS (${visibleUserMessageSubQuery}) AND NOT EXISTS (${answeredQuestionSubQuery}))`;
}
private applyFilters(
queryBuilder: SelectQueryBuilder<AgentChatThreadEntity>,
{
scope,
hasErrorOnly,
userNeverEngagedOnly,
searchTerm,
}: Pick<
GlobalChatThreadsArgs,
'scope' | 'hasErrorOnly' | 'userNeverEngagedOnly' | 'searchTerm'
>,
): SelectQueryBuilder<AgentChatThreadEntity> {
queryBuilder
.innerJoin(
'thread.workspace',
'workspace',
'"workspace"."allowImpersonation" = true AND "workspace"."deletedAt" IS NULL',
)
.leftJoin('thread.userWorkspace', 'userWorkspace')
.leftJoin('userWorkspace.user', 'user')
.withDeleted()
.setParameter(
'setupThreadNamespace',
WORKSPACE_SETUP_CHAT_THREAD_ID_NAMESPACE,
)
.setParameter('userMessageRole', AgentMessageRole.USER)
.setParameter('askQuestionsToolName', ASK_QUESTIONS_TOOL_NAME)
.setParameter('answeredQuestionStatus', ANSWERED_ASK_QUESTIONS_STATUS);
if (scope === AdminChatThreadScope.ONBOARDING) {
queryBuilder.andWhere(this.buildOnboardingThreadPredicate(queryBuilder));
}
if (hasErrorOnly) {
queryBuilder.andWhere('"thread"."lastStreamError" IS NOT NULL');
}
if (userNeverEngagedOnly) {
queryBuilder.andWhere(this.buildUserNeverEngagedPredicate(queryBuilder));
}
const trimmedSearchTerm = searchTerm?.trim();
if (isNonEmptyString(trimmedSearchTerm)) {
const escapedSearchTerm = trimmedSearchTerm.replace(/[\\%_]/g, '\\$&');
queryBuilder.andWhere(
new Brackets((subQuery) => {
subQuery
.where('"workspace"."displayName" ILIKE :searchPattern')
.orWhere('"user"."email" ILIKE :searchPattern')
.orWhere('"thread"."id"::text ILIKE :searchPattern');
}),
{ searchPattern: `%${escapedSearchTerm}%` },
);
}
return queryBuilder;
}
async getGlobalChatThreads({
scope,
hasErrorOnly,
userNeverEngagedOnly,
searchTerm,
sortBy,
sortDirection,
limit,
offset,
}: GlobalChatThreadsArgs): Promise<PaginatedAdminChatThreadsDTO> {
const sanitizedLimit = Math.min(
Math.max(limit, 1),
ADMIN_CHAT_THREADS_MAX_PAGE_SIZE,
);
const sanitizedOffset = Math.max(offset, 0);
const filterArgs = {
scope,
hasErrorOnly,
userNeverEngagedOnly,
searchTerm,
};
const orderExpression = ORDER_EXPRESSION_BY_SORT_FIELD[sortBy];
const orderDirection: 'ASC' | 'DESC' =
sortDirection === AdminChatThreadSortDirection.ASC ? 'ASC' : 'DESC';
const listQueryBuilder = this.applyFilters(
this.agentChatThreadRepository.createQueryBuilder('thread'),
filterArgs,
);
const rows = await listQueryBuilder
.leftJoin('thread.messages', 'message', '"message"."isHidden" = false')
.select('thread.id', 'id')
.addSelect('thread.title', 'title')
.addSelect('thread.workspaceId', 'workspaceId')
.addSelect('thread.userWorkspaceId', 'userWorkspaceId')
.addSelect('thread.deletedAt', 'deletedAt')
.addSelect('thread.createdAt', 'createdAt')
.addSelect('thread.updatedAt', 'updatedAt')
.addSelect('workspace.displayName', 'workspaceDisplayName')
.addSelect('user.email', 'userEmail')
.addSelect('user.firstName', 'userFirstName')
.addSelect('user.lastName', 'userLastName')
.addSelect('"thread"."lastStreamError" IS NOT NULL', 'hasError')
.addSelect(
this.buildOnboardingThreadPredicate(listQueryBuilder),
'isOnboardingThread',
)
.addSelect('COUNT("message"."id")::int', 'messageCount')
.addSelect(
`(
(COUNT("message"."id") FILTER (WHERE "message"."role" = :userMessageRole))
+ (${this.buildAnsweredQuestionSubQuery(listQueryBuilder, 'COUNT(*)')})
)::int`,
'userReplyCount',
)
.groupBy('"thread"."id"')
.addGroupBy('"workspace"."id"')
.addGroupBy('"userWorkspace"."id"')
.addGroupBy('"user"."id"')
.orderBy(orderExpression, orderDirection)
.addOrderBy('"thread"."id"', 'ASC')
.limit(sanitizedLimit)
.offset(sanitizedOffset)
.getRawMany<GlobalChatThreadRawRow>();
const totalCount = await this.applyFilters(
this.agentChatThreadRepository.createQueryBuilder('thread'),
filterArgs,
).getCount();
const threads: AdminChatThreadListItemDTO[] = rows.map((row) => ({
id: row.id,
title: row.title,
workspaceId: row.workspaceId,
workspaceDisplayName: row.workspaceDisplayName,
userWorkspaceId: row.userWorkspaceId,
userEmail: row.userEmail,
userFirstName: row.userFirstName,
userLastName: row.userLastName,
messageCount: row.messageCount,
userReplyCount: row.userReplyCount,
hasError: row.hasError,
isOnboardingThread: row.isOnboardingThread,
deletedAt: row.deletedAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}));
return {
threads,
totalCount,
hasMore: sanitizedOffset + threads.length < totalCount,
};
}
}
@@ -0,0 +1,2 @@
export const WORKSPACE_SETUP_CHAT_THREAD_ID_NAMESPACE =
'1e9195f3-c26a-4bfc-961e-dc317b4badbd';
@@ -13,6 +13,7 @@ import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WORKSPACE_SETUP_CHAT_THREAD_ID_NAMESPACE } from 'src/engine/metadata-modules/ai/ai-chat/constants/workspace-setup-chat-thread-id-namespace.constant';
import { type AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
import { WorkspaceSetupChatOutcome } from 'src/engine/metadata-modules/ai/ai-chat/enums/workspace-setup-chat-outcome.enum';
import { AgentChatStreamingService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service';
@@ -23,9 +24,6 @@ import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
const WORKSPACE_SETUP_CHAT_THREAD_ID_NAMESPACE =
'1e9195f3-c26a-4bfc-961e-dc317b4badbd';
const WORKSPACE_SETUP_CHAT_THREAD_TITLE = msg`Workspace setup`;
type StartWorkspaceSetupChatServiceResult =
@@ -0,0 +1,664 @@
import { randomUUID } from 'crypto';
import { gql } from 'graphql-tag';
import { type DataSource } from 'typeorm';
import { v5 } from 'uuid';
import { makeAdminPanelAPIRequestWithGuestRole } from 'test/integration/graphql/suites/admin-panel/utils/make-admin-panel-api-request-with-guest-role.util';
import { makeAdminPanelAPIRequest } from 'test/integration/twenty-config/utils/make-admin-panel-api-request.util';
import { WORKSPACE_SETUP_CHAT_THREAD_ID_NAMESPACE } from 'src/engine/metadata-modules/ai/ai-chat/constants/workspace-setup-chat-thread-id-namespace.constant';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const GET_ADMIN_CHAT_THREADS = gql`
query GetAdminChatThreads(
$scope: AdminChatThreadScope
$hasErrorOnly: Boolean
$userNeverEngagedOnly: Boolean
$searchTerm: String
$sortBy: AdminChatThreadSortField
$sortDirection: AdminChatThreadSortDirection
$limit: Int
$offset: Int
) {
getAdminChatThreads(
scope: $scope
hasErrorOnly: $hasErrorOnly
userNeverEngagedOnly: $userNeverEngagedOnly
searchTerm: $searchTerm
sortBy: $sortBy
sortDirection: $sortDirection
limit: $limit
offset: $offset
) {
totalCount
hasMore
threads {
id
title
workspaceId
workspaceDisplayName
userWorkspaceId
userEmail
messageCount
userReplyCount
hasError
isOnboardingThread
deletedAt
createdAt
updatedAt
}
}
}
`;
const GET_ADMIN_CHAT_THREAD_MESSAGES = gql`
query GetAdminChatThreadMessages($threadId: UUID!) {
getAdminChatThreadMessages(threadId: $threadId) {
thread {
id
messageCount
conversationSize
}
messages {
id
role
isHidden
parts {
type
orderIndex
textContent
reasoningContent
toolName
toolCallId
toolInput
toolOutput
state
errorMessage
}
}
}
}
`;
type ThreadsResult = {
totalCount: number;
hasMore: boolean;
threads: {
id: string;
messageCount: number;
userReplyCount: number;
hasError: boolean;
isOnboardingThread: boolean;
userEmail: string | null;
}[];
};
describe('Admin panel global chat threads (integration)', () => {
let dataSource: DataSource;
let userWorkspaceId: string;
let userEmail: string;
let kickoffThreadId: string;
let deterministicThreadId: string;
let regularThreadId: string;
let answeredQuestionThreadId: string;
let pendingQuestionThreadId: string;
const seededThreadIds: string[] = [];
const seededMessageIds: string[] = [];
const seededPartIds: string[] = [];
const insertThread = async ({
id,
title,
lastStreamError,
}: {
id: string;
title: string;
lastStreamError?: object;
}): Promise<string> => {
await dataSource.query(
`INSERT INTO core."agentChatThread"
(id, "workspaceId", "userWorkspaceId", title, "lastStreamError")
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET "lastStreamError" = EXCLUDED."lastStreamError"`,
[
id,
SEED_APPLE_WORKSPACE_ID,
userWorkspaceId,
title,
lastStreamError ? JSON.stringify(lastStreamError) : null,
],
);
seededThreadIds.push(id);
return id;
};
const insertMessage = async ({
threadId,
role,
isHidden = false,
createdAt,
}: {
threadId: string;
role: 'user' | 'assistant';
isHidden?: boolean;
createdAt: string;
}): Promise<string> => {
const id = randomUUID();
await dataSource.query(
`INSERT INTO core."agentMessage"
(id, "workspaceId", "threadId", role, "isHidden", "createdAt")
VALUES ($1, $2, $3, $4, $5, $6)`,
[id, SEED_APPLE_WORKSPACE_ID, threadId, role, isHidden, createdAt],
);
seededMessageIds.push(id);
return id;
};
const insertPart = async ({
messageId,
orderIndex,
type,
textContent,
reasoningContent,
toolName,
toolCallId,
toolInput,
toolOutput,
state,
}: {
messageId: string;
orderIndex: number;
type: string;
textContent?: string;
reasoningContent?: string;
toolName?: string;
toolCallId?: string;
toolInput?: object;
toolOutput?: object;
state?: string;
}): Promise<string> => {
const id = randomUUID();
await dataSource.query(
`INSERT INTO core."agentMessagePart"
(id, "workspaceId", "messageId", "orderIndex", type, "textContent",
"reasoningContent", "toolName", "toolCallId", "toolInput",
"toolOutput", state)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
[
id,
SEED_APPLE_WORKSPACE_ID,
messageId,
orderIndex,
type,
textContent ?? null,
reasoningContent ?? null,
toolName ?? null,
toolCallId ?? null,
toolInput ? JSON.stringify(toolInput) : null,
toolOutput ? JSON.stringify(toolOutput) : null,
state ?? null,
],
);
seededPartIds.push(id);
return id;
};
const fetchThreads = async (
variables: Record<string, unknown>,
): Promise<ThreadsResult> => {
const response = await makeAdminPanelAPIRequest({
query: GET_ADMIN_CHAT_THREADS,
variables,
});
expect(response.body.errors).toBeUndefined();
return response.body.data?.getAdminChatThreads;
};
beforeAll(async () => {
dataSource = global.testDataSource;
const [firstUserWorkspace] = await dataSource.query(
`SELECT "userWorkspace".id, "user".email
FROM core."userWorkspace" "userWorkspace"
JOIN core."user" "user" ON "user".id = "userWorkspace"."userId"
WHERE "userWorkspace"."workspaceId" = $1
AND "userWorkspace"."deletedAt" IS NULL
ORDER BY "userWorkspace"."createdAt" ASC
LIMIT 1`,
[SEED_APPLE_WORKSPACE_ID],
);
userWorkspaceId = firstUserWorkspace.id;
userEmail = firstUserWorkspace.email;
kickoffThreadId = await insertThread({
id: randomUUID(),
title: 'integration-onboarding-kickoff-thread',
});
const hiddenKickoffMessageId = await insertMessage({
threadId: kickoffThreadId,
role: 'user',
isHidden: true,
createdAt: '2026-01-01T00:00:00Z',
});
await insertPart({
messageId: hiddenKickoffMessageId,
orderIndex: 0,
type: 'text',
textContent: 'kickoff prompt with company context',
});
await insertMessage({
threadId: kickoffThreadId,
role: 'user',
createdAt: '2026-01-01T00:01:00Z',
});
const assistantMessageId = await insertMessage({
threadId: kickoffThreadId,
role: 'assistant',
createdAt: '2026-01-01T00:02:00Z',
});
await insertPart({
messageId: assistantMessageId,
orderIndex: 1,
type: 'tool-call',
toolName: 'create_many_object_metadata',
toolCallId: 'call-1',
toolInput: { objects: [{ nameSingular: 'listing' }] },
toolOutput: { success: true },
state: 'output-available',
});
await insertPart({
messageId: assistantMessageId,
orderIndex: 0,
type: 'reasoning',
reasoningContent: 'planning the data model',
});
deterministicThreadId = await insertThread({
id: v5(
`${SEED_APPLE_WORKSPACE_ID}:${userWorkspaceId}`,
WORKSPACE_SETUP_CHAT_THREAD_ID_NAMESPACE,
),
title: 'integration-onboarding-deterministic-thread',
});
regularThreadId = await insertThread({
id: randomUUID(),
title: 'integration-regular-thread',
lastStreamError: { message: 'stream failed' },
});
const regularAssistantMessageId = await insertMessage({
threadId: regularThreadId,
role: 'assistant',
createdAt: '2026-01-01T00:03:00Z',
});
await insertPart({
messageId: regularAssistantMessageId,
orderIndex: 0,
type: 'text',
textContent: 'assistant reply',
});
const questionItems = [
{
header: 'Email type',
question: 'Which mailbox should we sync?',
options: [{ label: 'Work' }, { label: 'Personal' }],
},
];
answeredQuestionThreadId = await insertThread({
id: randomUUID(),
title: 'integration-answered-question-thread',
});
await insertMessage({
threadId: answeredQuestionThreadId,
role: 'user',
isHidden: true,
createdAt: '2026-01-01T00:04:00Z',
});
const answeredQuestionMessageId = await insertMessage({
threadId: answeredQuestionThreadId,
role: 'assistant',
createdAt: '2026-01-01T00:05:00Z',
});
await insertPart({
messageId: answeredQuestionMessageId,
orderIndex: 0,
type: 'tool-ask_questions',
toolName: 'ask_questions',
toolCallId: 'call-answered-questions',
toolInput: { questions: questionItems },
toolOutput: {
success: true,
message: 'User answered the questions.',
result: {
questions: questionItems,
status: 'answered',
answers: [{ questionIndex: 0, selectedOptionIndices: [0] }],
},
},
state: 'output-available',
});
pendingQuestionThreadId = await insertThread({
id: randomUUID(),
title: 'integration-pending-question-thread',
});
await insertMessage({
threadId: pendingQuestionThreadId,
role: 'user',
isHidden: true,
createdAt: '2026-01-01T00:06:00Z',
});
const pendingQuestionMessageId = await insertMessage({
threadId: pendingQuestionThreadId,
role: 'assistant',
createdAt: '2026-01-01T00:07:00Z',
});
await insertPart({
messageId: pendingQuestionMessageId,
orderIndex: 0,
type: 'tool-ask_questions',
toolName: 'ask_questions',
toolCallId: 'call-pending-questions',
toolInput: { questions: questionItems },
toolOutput: {
success: true,
message: 'Questions presented to the user; awaiting their answer.',
result: { questions: questionItems, status: 'pending' },
},
state: 'output-available',
});
});
afterAll(async () => {
if (seededPartIds.length > 0) {
await dataSource.query(
`DELETE FROM core."agentMessagePart" WHERE id = ANY($1)`,
[seededPartIds],
);
}
if (seededMessageIds.length > 0) {
await dataSource.query(
`DELETE FROM core."agentMessage" WHERE id = ANY($1)`,
[seededMessageIds],
);
}
if (seededThreadIds.length > 0) {
await dataSource.query(
`DELETE FROM core."agentChatThread" WHERE id = ANY($1)`,
[seededThreadIds],
);
}
await dataSource.query(
`UPDATE core."workspace" SET "allowImpersonation" = true WHERE id = $1`,
[SEED_APPLE_WORKSPACE_ID],
);
});
describe('getAdminChatThreads', () => {
it('returns only onboarding threads for the ONBOARDING scope', async () => {
const result = await fetchThreads({ scope: 'ONBOARDING', limit: 100 });
const threadIds = result.threads.map((thread) => thread.id);
expect(threadIds).toContain(kickoffThreadId);
expect(threadIds).toContain(deterministicThreadId);
expect(threadIds).not.toContain(regularThreadId);
for (const thread of result.threads) {
expect(thread.isOnboardingThread).toBe(true);
}
});
it('defaults to every scope when none is given', async () => {
const result = await fetchThreads({ limit: 100 });
const threadIds = result.threads.map((thread) => thread.id);
expect(threadIds).toContain(regularThreadId);
expect(threadIds).toContain(kickoffThreadId);
});
it('returns all threads with the onboarding flag for the ALL scope', async () => {
const result = await fetchThreads({
scope: 'ALL',
searchTerm: regularThreadId,
});
expect(result.totalCount).toBe(1);
expect(result.threads).toHaveLength(1);
expect(result.threads[0]).toMatchObject({
id: regularThreadId,
isOnboardingThread: false,
hasError: true,
messageCount: 1,
userReplyCount: 0,
userEmail,
});
});
it('counts only visible messages and user replies', async () => {
const result = await fetchThreads({
scope: 'ALL',
searchTerm: kickoffThreadId,
});
expect(result.threads[0]).toMatchObject({
id: kickoffThreadId,
isOnboardingThread: true,
messageCount: 2,
userReplyCount: 1,
hasError: false,
});
});
it('filters threads with errors via hasErrorOnly', async () => {
const result = await fetchThreads({
scope: 'ALL',
hasErrorOnly: true,
limit: 100,
});
const threadIds = result.threads.map((thread) => thread.id);
expect(threadIds).toContain(regularThreadId);
expect(threadIds).not.toContain(kickoffThreadId);
});
it('filters threads without user replies via userNeverEngagedOnly', async () => {
const result = await fetchThreads({
scope: 'ONBOARDING',
userNeverEngagedOnly: true,
limit: 100,
});
const threadIds = result.threads.map((thread) => thread.id);
expect(threadIds).toContain(deterministicThreadId);
expect(threadIds).toContain(pendingQuestionThreadId);
expect(threadIds).not.toContain(kickoffThreadId);
expect(threadIds).not.toContain(answeredQuestionThreadId);
});
it('counts an answered question card as a user reply', async () => {
const result = await fetchThreads({
scope: 'ALL',
searchTerm: answeredQuestionThreadId,
});
expect(result.threads[0]).toMatchObject({
id: answeredQuestionThreadId,
messageCount: 1,
userReplyCount: 1,
});
});
it('does not count a pending question card as a user reply', async () => {
const result = await fetchThreads({
scope: 'ALL',
searchTerm: pendingQuestionThreadId,
});
expect(result.threads[0]).toMatchObject({
id: pendingQuestionThreadId,
messageCount: 1,
userReplyCount: 0,
});
});
it('sorts by message count', async () => {
const result = await fetchThreads({
scope: 'ONBOARDING',
sortBy: 'MESSAGE_COUNT',
sortDirection: 'ASC',
limit: 100,
});
const threadIds = result.threads.map((thread) => thread.id);
expect(threadIds.indexOf(deterministicThreadId)).toBeLessThan(
threadIds.indexOf(kickoffThreadId),
);
});
it('sorts by reply count, counting answered question cards', async () => {
const result = await fetchThreads({
scope: 'ALL',
sortBy: 'REPLY_COUNT',
sortDirection: 'DESC',
limit: 100,
});
const threadIds = result.threads.map((thread) => thread.id);
expect(threadIds.indexOf(answeredQuestionThreadId)).toBeLessThan(
threadIds.indexOf(pendingQuestionThreadId),
);
expect(threadIds.indexOf(kickoffThreadId)).toBeLessThan(
threadIds.indexOf(pendingQuestionThreadId),
);
});
it('paginates with totalCount and hasMore', async () => {
const result = await fetchThreads({ scope: 'ONBOARDING', limit: 1 });
expect(result.threads).toHaveLength(1);
expect(result.totalCount).toBeGreaterThanOrEqual(2);
expect(result.hasMore).toBe(true);
});
it('finds threads by user email via searchTerm', async () => {
const result = await fetchThreads({
scope: 'ALL',
searchTerm: userEmail,
limit: 100,
});
const threadIds = result.threads.map((thread) => thread.id);
expect(threadIds).toContain(kickoffThreadId);
expect(threadIds).toContain(regularThreadId);
});
it('excludes workspaces that disabled support access', async () => {
await dataSource.query(
`UPDATE core."workspace" SET "allowImpersonation" = false WHERE id = $1`,
[SEED_APPLE_WORKSPACE_ID],
);
try {
const result = await fetchThreads({
scope: 'ALL',
searchTerm: kickoffThreadId,
});
expect(result.totalCount).toBe(0);
expect(result.threads).toHaveLength(0);
} finally {
await dataSource.query(
`UPDATE core."workspace" SET "allowImpersonation" = true WHERE id = $1`,
[SEED_APPLE_WORKSPACE_ID],
);
}
});
it('rejects a caller without the SECURITY permission flag', async () => {
const response = await makeAdminPanelAPIRequestWithGuestRole({
query: GET_ADMIN_CHAT_THREADS,
variables: {},
});
expect(response.body.errors).toBeDefined();
expect(response.body.data?.getAdminChatThreads).toBeFalsy();
});
});
describe('getAdminChatThreadMessages', () => {
it('returns the hidden kickoff first with enriched ordered parts', async () => {
const response = await makeAdminPanelAPIRequest({
query: GET_ADMIN_CHAT_THREAD_MESSAGES,
variables: { threadId: kickoffThreadId },
});
expect(response.body.errors).toBeUndefined();
const result = response.body.data?.getAdminChatThreadMessages;
expect(result.thread.messageCount).toBe(2);
expect(result.messages).toHaveLength(3);
expect(result.messages[0]).toMatchObject({
role: 'USER',
isHidden: true,
});
expect(result.messages[0].parts[0].textContent).toBe(
'kickoff prompt with company context',
);
const assistantMessage = result.messages.find(
(message: { role: string }) => message.role === 'ASSISTANT',
);
expect(
assistantMessage.parts.map(
(part: { orderIndex: number }) => part.orderIndex,
),
).toEqual([0, 1]);
expect(assistantMessage.parts[1]).toMatchObject({
type: 'tool-call',
toolName: 'create_many_object_metadata',
toolCallId: 'call-1',
toolInput: { objects: [{ nameSingular: 'listing' }] },
toolOutput: { success: true },
state: 'output-available',
});
expect(assistantMessage.parts[0]).toMatchObject({
type: 'reasoning',
reasoningContent: 'planning the data model',
});
});
});
});
@@ -87,6 +87,7 @@ export enum SettingsPath {
AdminPanelWorkspaceDetail = 'admin-panel/workspaces/:workspaceId',
AdminPanelApplicationRegistrationDetail = 'admin-panel/applications/registrations/:applicationRegistrationId',
AdminPanelWorkspaceChatThread = 'admin-panel/workspaces/:workspaceId/threads/:threadId',
AdminPanelChats = 'admin-panel/chats',
Roles = 'members/roles',
RoleCreate = 'members/roles/create',