[AI] Add thread actions, filters, and archive support (#20068)

## PR Description

### Summary
- Add AI chat thread actions: rename, archive (soft-delete via
`deletedAt`), and hard-delete with confirmation.
- Add chat thread filtering by status (active/archived/all), group-by
mode, and last activity.
- Rework drawer/side-panel thread lists to share thread sections, item
menus, archive icons, and empty-state behavior.
- Extend server chat thread model/API with `deletedAt`, mutations,
broadcasts, and archive-aware stream guards.

### Decisions
- Two-stage lifecycle: Archive sets `deletedAt` (soft); Delete is a
separate action on archived threads that hard-deletes the row. Aligns
with Twenty's soft-delete convention (Felix's suggestion).
- `lastMessageAt` is derived from `MAX(agentMessage.createdAt)` on read,
not stored. List query does inline aggregation for sort; `@ResolveField`
covers single-thread / mutation paths so the schema contract is honest
everywhere. Matches `timeline-messaging.service.ts` precedent and the
existing `totalInputCredits` / `totalOutputCredits` `@ResolveField`
pattern in the same resolver.
- Replaced auto-CRUD `chatThreads` (cursor-paginated Connection) with a
custom `[AgentChatThreadDTO!]` resolver. Frontend metadata-store treats
threads as a flat collection and filters/sorts client-side, so cursor
pagination was performative.
- Sending in an archived chat unarchives it optimistically on the client
and authoritatively on the server.
- Grouping and last-activity filtering use `lastMessageAt ?? updatedAt`
so archive/rename don't bump threads in the list.
- Kept metadata-store core API unchanged; AI chat uses the same local
cast pattern already used by other metadata-store partial updates.


https://github.com/user-attachments/assets/1b179b7b-1a2a-4a7a-aa0a-c88f6f051a87
This commit is contained in:
nitin
2026-04-30 21:12:10 +05:30
committed by GitHub
parent 4b76457217
commit e1828b6f41
111 changed files with 2915 additions and 1139 deletions
@@ -2609,19 +2609,6 @@ type Skill {
updatedAt: DateTime!
}
type AgentChatThread {
id: UUID!
title: String
totalInputTokens: Int!
totalOutputTokens: Int!
contextWindowTokens: Int
conversationSize: Int!
totalInputCredits: Float!
totalOutputCredits: Float!
createdAt: DateTime!
updatedAt: DateTime!
}
type AgentMessage {
id: UUID!
threadId: UUID!
@@ -2634,6 +2621,21 @@ type AgentMessage {
createdAt: DateTime!
}
type AgentChatThread {
id: ID!
title: String
totalInputTokens: Int!
totalOutputTokens: Int!
contextWindowTokens: Int
conversationSize: Int!
totalInputCredits: Float!
totalOutputCredits: Float!
createdAt: DateTime!
updatedAt: DateTime!
deletedAt: DateTime
lastMessageAt: DateTime
}
type AiSystemPromptSection {
title: String!
content: String!
@@ -2661,22 +2663,6 @@ type AgentChatEvent {
event: JSON!
}
type AgentChatThreadEdge {
"""The node containing the AgentChatThread"""
node: AgentChatThread!
"""Cursor for this node."""
cursor: ConnectionCursor!
}
type AgentChatThreadConnection {
"""Paging information"""
pageInfo: PageInfo!
"""Array of edges."""
edges: [AgentChatThreadEdge!]!
}
type AgentTurnEvaluation {
id: UUID!
turnId: UUID!
@@ -2993,22 +2979,13 @@ type Query {
webhooks: [Webhook!]!
webhook(id: UUID!): Webhook
minimalMetadata: MinimalMetadata!
chatThreads: [AgentChatThread!]!
chatThread(id: UUID!): AgentChatThread!
chatMessages(threadId: UUID!): [AgentMessage!]!
chatStreamCatchupChunks(threadId: UUID!): ChatStreamCatchupChunks!
getAiSystemPromptPreview: AiSystemPromptPreview!
skills: [Skill!]!
skill(id: UUID!): Skill
chatThreads(
"""Limit or page results."""
paging: CursorPaging! = {first: 10}
"""Specify to filter the records returned."""
filter: AgentChatThreadFilter! = {}
"""Specify to sort results."""
sorting: [AgentChatThreadSort!]! = [{field: updatedAt, direction: DESC}]
): AgentChatThreadConnection!
agentTurns(agentId: UUID!): [AgentTurn!]!
checkUserExists(email: String!, captchaToken: String): CheckUserExist!
checkWorkspaceInviteHashIsValid(inviteHash: String!): WorkspaceInviteHashValid!
@@ -3057,56 +3034,6 @@ input AgentIdInput {
id: UUID!
}
input AgentChatThreadFilter {
and: [AgentChatThreadFilter!]
or: [AgentChatThreadFilter!]
id: UUIDFilterComparison
updatedAt: DateFieldComparison
}
input DateFieldComparison {
is: Boolean
isNot: Boolean
eq: DateTime
neq: DateTime
gt: DateTime
gte: DateTime
lt: DateTime
lte: DateTime
in: [DateTime!]
notIn: [DateTime!]
between: DateFieldComparisonBetween
notBetween: DateFieldComparisonBetween
}
input DateFieldComparisonBetween {
lower: DateTime!
upper: DateTime!
}
input AgentChatThreadSort {
field: AgentChatThreadSortFields!
direction: SortDirection!
nulls: SortNulls
}
enum AgentChatThreadSortFields {
id
updatedAt
}
"""Sort Directions"""
enum SortDirection {
ASC
DESC
}
"""Sort Nulls Options"""
enum SortNulls {
NULLS_FIRST
NULLS_LAST
}
input EventLogQueryInput {
table: EventLogTable!
filters: EventLogFiltersInput
@@ -3292,6 +3219,10 @@ type Mutation {
createChatThread: AgentChatThread!
sendChatMessage(threadId: UUID!, text: String!, messageId: UUID!, browsingContext: JSON, modelId: String, fileIds: [UUID!]): SendChatMessageResult!
stopAgentChatStream(threadId: UUID!): Boolean!
renameChatThread(id: UUID!, title: String!): AgentChatThread!
archiveChatThread(id: UUID!): AgentChatThread!
unarchiveChatThread(id: UUID!): AgentChatThread!
deleteChatThread(id: UUID!): Boolean!
deleteQueuedChatMessage(messageId: UUID!): Boolean!
createSkill(input: CreateSkillInput!): Skill!
updateSkill(input: UpdateSkillInput!): Skill!
@@ -2304,20 +2304,6 @@ export interface Skill {
__typename: 'Skill'
}
export interface AgentChatThread {
id: Scalars['UUID']
title?: Scalars['String']
totalInputTokens: Scalars['Int']
totalOutputTokens: Scalars['Int']
contextWindowTokens?: Scalars['Int']
conversationSize: Scalars['Int']
totalInputCredits: Scalars['Float']
totalOutputCredits: Scalars['Float']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
__typename: 'AgentChatThread'
}
export interface AgentMessage {
id: Scalars['UUID']
threadId: Scalars['UUID']
@@ -2331,6 +2317,22 @@ export interface AgentMessage {
__typename: 'AgentMessage'
}
export interface AgentChatThread {
id: Scalars['ID']
title?: Scalars['String']
totalInputTokens: Scalars['Int']
totalOutputTokens: Scalars['Int']
contextWindowTokens?: Scalars['Int']
conversationSize: Scalars['Int']
totalInputCredits: Scalars['Float']
totalOutputCredits: Scalars['Float']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
deletedAt?: Scalars['DateTime']
lastMessageAt?: Scalars['DateTime']
__typename: 'AgentChatThread'
}
export interface AiSystemPromptSection {
title: Scalars['String']
content: Scalars['String']
@@ -2363,22 +2365,6 @@ export interface AgentChatEvent {
__typename: 'AgentChatEvent'
}
export interface AgentChatThreadEdge {
/** The node containing the AgentChatThread */
node: AgentChatThread
/** Cursor for this node. */
cursor: Scalars['ConnectionCursor']
__typename: 'AgentChatThreadEdge'
}
export interface AgentChatThreadConnection {
/** Paging information */
pageInfo: PageInfo
/** Array of edges. */
edges: AgentChatThreadEdge[]
__typename: 'AgentChatThreadConnection'
}
export interface AgentTurnEvaluation {
id: Scalars['UUID']
turnId: Scalars['UUID']
@@ -2591,13 +2577,13 @@ export interface Query {
webhooks: Webhook[]
webhook?: Webhook
minimalMetadata: MinimalMetadata
chatThreads: AgentChatThread[]
chatThread: AgentChatThread
chatMessages: AgentMessage[]
chatStreamCatchupChunks: ChatStreamCatchupChunks
getAiSystemPromptPreview: AiSystemPromptPreview
skills: Skill[]
skill?: Skill
chatThreads: AgentChatThreadConnection
agentTurns: AgentTurn[]
checkUserExists: CheckUserExist
checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid
@@ -2633,16 +2619,6 @@ export interface Query {
__typename: 'Query'
}
export type AgentChatThreadSortFields = 'id' | 'updatedAt'
/** Sort Directions */
export type SortDirection = 'ASC' | 'DESC'
/** Sort Nulls Options */
export type SortNulls = 'NULLS_FIRST' | 'NULLS_LAST'
export type EventLogTable = 'WORKSPACE_EVENT' | 'PAGEVIEW' | 'OBJECT_EVENT' | 'USAGE_EVENT' | 'APPLICATION_LOG'
export type UsageOperationType = 'AI_CHAT_TOKEN' | 'AI_WORKFLOW_TOKEN' | 'WORKFLOW_EXECUTION' | 'CODE_EXECUTION' | 'WEB_SEARCH'
@@ -2774,6 +2750,10 @@ export interface Mutation {
createChatThread: AgentChatThread
sendChatMessage: SendChatMessageResult
stopAgentChatStream: Scalars['Boolean']
renameChatThread: AgentChatThread
archiveChatThread: AgentChatThread
unarchiveChatThread: AgentChatThread
deleteChatThread: Scalars['Boolean']
deleteQueuedChatMessage: Scalars['Boolean']
createSkill: Skill
updateSkill: Skill
@@ -5319,6 +5299,20 @@ export interface SkillGenqlSelection{
__scalar?: boolean | number
}
export interface AgentMessageGenqlSelection{
id?: boolean | number
threadId?: boolean | number
turnId?: boolean | number
agentId?: boolean | number
role?: boolean | number
status?: boolean | number
parts?: AgentMessagePartGenqlSelection
processedAt?: boolean | number
createdAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface AgentChatThreadGenqlSelection{
id?: boolean | number
title?: boolean | number
@@ -5330,20 +5324,8 @@ export interface AgentChatThreadGenqlSelection{
totalOutputCredits?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface AgentMessageGenqlSelection{
id?: boolean | number
threadId?: boolean | number
turnId?: boolean | number
agentId?: boolean | number
role?: boolean | number
status?: boolean | number
parts?: AgentMessagePartGenqlSelection
processedAt?: boolean | number
createdAt?: boolean | number
deletedAt?: boolean | number
lastMessageAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
@@ -5385,24 +5367,6 @@ export interface AgentChatEventGenqlSelection{
__scalar?: boolean | number
}
export interface AgentChatThreadEdgeGenqlSelection{
/** The node containing the AgentChatThread */
node?: AgentChatThreadGenqlSelection
/** Cursor for this node. */
cursor?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface AgentChatThreadConnectionGenqlSelection{
/** Paging information */
pageInfo?: PageInfoGenqlSelection
/** Array of edges. */
edges?: AgentChatThreadEdgeGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface AgentTurnEvaluationGenqlSelection{
id?: boolean | number
turnId?: boolean | number
@@ -5617,19 +5581,13 @@ export interface QueryGenqlSelection{
webhooks?: WebhookGenqlSelection
webhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
minimalMetadata?: MinimalMetadataGenqlSelection
chatThreads?: AgentChatThreadGenqlSelection
chatThread?: (AgentChatThreadGenqlSelection & { __args: {id: Scalars['UUID']} })
chatMessages?: (AgentMessageGenqlSelection & { __args: {threadId: Scalars['UUID']} })
chatStreamCatchupChunks?: (ChatStreamCatchupChunksGenqlSelection & { __args: {threadId: Scalars['UUID']} })
getAiSystemPromptPreview?: AiSystemPromptPreviewGenqlSelection
skills?: SkillGenqlSelection
skill?: (SkillGenqlSelection & { __args: {id: Scalars['UUID']} })
chatThreads?: (AgentChatThreadConnectionGenqlSelection & { __args: {
/** Limit or page results. */
paging: CursorPaging,
/** Specify to filter the records returned. */
filter: AgentChatThreadFilter,
/** Specify to sort results. */
sorting: AgentChatThreadSort[]} })
agentTurns?: (AgentTurnGenqlSelection & { __args: {agentId: Scalars['UUID']} })
checkUserExists?: (CheckUserExistGenqlSelection & { __args: {email: Scalars['String'], captchaToken?: (Scalars['String'] | null)} })
checkWorkspaceInviteHashIsValid?: (WorkspaceInviteHashValidGenqlSelection & { __args: {inviteHash: Scalars['String']} })
@@ -5676,14 +5634,6 @@ export interface AgentIdInput {
/** The id of the agent. */
id: Scalars['UUID']}
export interface AgentChatThreadFilter {and?: (AgentChatThreadFilter[] | null),or?: (AgentChatThreadFilter[] | null),id?: (UUIDFilterComparison | null),updatedAt?: (DateFieldComparison | null)}
export interface DateFieldComparison {is?: (Scalars['Boolean'] | null),isNot?: (Scalars['Boolean'] | null),eq?: (Scalars['DateTime'] | null),neq?: (Scalars['DateTime'] | null),gt?: (Scalars['DateTime'] | null),gte?: (Scalars['DateTime'] | null),lt?: (Scalars['DateTime'] | null),lte?: (Scalars['DateTime'] | null),in?: (Scalars['DateTime'][] | null),notIn?: (Scalars['DateTime'][] | null),between?: (DateFieldComparisonBetween | null),notBetween?: (DateFieldComparisonBetween | null)}
export interface DateFieldComparisonBetween {lower: Scalars['DateTime'],upper: Scalars['DateTime']}
export interface AgentChatThreadSort {field: AgentChatThreadSortFields,direction: SortDirection,nulls?: (SortNulls | null)}
export interface EventLogQueryInput {table: EventLogTable,filters?: (EventLogFiltersInput | null),first?: (Scalars['Int'] | null),after?: (Scalars['String'] | null)}
export interface EventLogFiltersInput {eventType?: (Scalars['String'] | null),userWorkspaceId?: (Scalars['String'] | null),dateRange?: (EventLogDateRangeInput | null),recordId?: (Scalars['String'] | null),objectMetadataId?: (Scalars['String'] | null)}
@@ -5825,6 +5775,10 @@ export interface MutationGenqlSelection{
createChatThread?: AgentChatThreadGenqlSelection
sendChatMessage?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], text: Scalars['String'], messageId: Scalars['UUID'], browsingContext?: (Scalars['JSON'] | null), modelId?: (Scalars['String'] | null), fileIds?: (Scalars['UUID'][] | null)} })
stopAgentChatStream?: { __args: {threadId: Scalars['UUID']} }
renameChatThread?: (AgentChatThreadGenqlSelection & { __args: {id: Scalars['UUID'], title: Scalars['String']} })
archiveChatThread?: (AgentChatThreadGenqlSelection & { __args: {id: Scalars['UUID']} })
unarchiveChatThread?: (AgentChatThreadGenqlSelection & { __args: {id: Scalars['UUID']} })
deleteChatThread?: { __args: {id: Scalars['UUID']} }
deleteQueuedChatMessage?: { __args: {messageId: Scalars['UUID']} }
createSkill?: (SkillGenqlSelection & { __args: {input: CreateSkillInput} })
updateSkill?: (SkillGenqlSelection & { __args: {input: UpdateSkillInput} })
@@ -8045,14 +7999,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const AgentChatThread_possibleTypes: string[] = ['AgentChatThread']
export const isAgentChatThread = (obj?: { __typename?: any } | null): obj is AgentChatThread => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAgentChatThread"')
return AgentChatThread_possibleTypes.includes(obj.__typename)
}
const AgentMessage_possibleTypes: string[] = ['AgentMessage']
export const isAgentMessage = (obj?: { __typename?: any } | null): obj is AgentMessage => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAgentMessage"')
@@ -8061,6 +8007,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const AgentChatThread_possibleTypes: string[] = ['AgentChatThread']
export const isAgentChatThread = (obj?: { __typename?: any } | null): obj is AgentChatThread => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAgentChatThread"')
return AgentChatThread_possibleTypes.includes(obj.__typename)
}
const AiSystemPromptSection_possibleTypes: string[] = ['AiSystemPromptSection']
export const isAiSystemPromptSection = (obj?: { __typename?: any } | null): obj is AiSystemPromptSection => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAiSystemPromptSection"')
@@ -8101,22 +8055,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const AgentChatThreadEdge_possibleTypes: string[] = ['AgentChatThreadEdge']
export const isAgentChatThreadEdge = (obj?: { __typename?: any } | null): obj is AgentChatThreadEdge => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAgentChatThreadEdge"')
return AgentChatThreadEdge_possibleTypes.includes(obj.__typename)
}
const AgentChatThreadConnection_possibleTypes: string[] = ['AgentChatThreadConnection']
export const isAgentChatThreadConnection = (obj?: { __typename?: any } | null): obj is AgentChatThreadConnection => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAgentChatThreadConnection"')
return AgentChatThreadConnection_possibleTypes.includes(obj.__typename)
}
const AgentTurnEvaluation_possibleTypes: string[] = ['AgentTurnEvaluation']
export const isAgentTurnEvaluation = (obj?: { __typename?: any } | null): obj is AgentTurnEvaluation => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAgentTurnEvaluation"')
@@ -8848,21 +8786,6 @@ export const enumAllMetadataName = {
webhook: 'webhook' as const
}
export const enumAgentChatThreadSortFields = {
id: 'id' as const,
updatedAt: 'updatedAt' as const
}
export const enumSortDirection = {
ASC: 'ASC' as const,
DESC: 'DESC' as const
}
export const enumSortNulls = {
NULLS_FIRST: 'NULLS_FIRST' as const,
NULLS_LAST: 'NULLS_LAST' as const
}
export const enumEventLogTable = {
WORKSPACE_EVENT: 'WORKSPACE_EVENT' as const,
PAGEVIEW: 'PAGEVIEW' as const,
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@ import { AgentChatSessionStartTimeEffect } from '@/ai/components/AgentChatSessio
import { AgentChatStreamingAutoScrollEffect } from '@/ai/components/AgentChatStreamingAutoScrollEffect';
import { AgentChatStreamingPartsDiffSyncEffect } from '@/ai/components/AgentChatStreamingPartsDiffSyncEffect';
import { AgentChatThreadInitializationEffect } from '@/ai/components/AgentChatThreadInitializationEffect';
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
import { Suspense } from 'react';
export const AgentChatProviderContent = ({
@@ -1,4 +1,4 @@
import { agentChatIsScrolledToBottomSelector } from '@/ai/states/agentChatIsScrolledToBottomSelector';
import { agentChatIsScrolledToBottomSelector } from '@/ai/states/selectors/agentChatIsScrolledToBottomSelector';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
import { scrollAiChatToBottom } from '@/ai/utils/scrollAiChatToBottom';
@@ -8,15 +8,15 @@ import {
} from '@/ai/states/agentChatDraftsByThreadIdState';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { agentChatThreadsLoadingState } from '@/ai/states/agentChatThreadsLoadingState';
import { agentChatThreadsSelector } from '@/ai/states/agentChatThreadsSelector';
import { agentChatUsageComponentFamilyState } from '@/ai/states/agentChatUsageComponentFamilyState';
import { agentChatVisibleThreadsSelector } from '@/ai/states/selectors/agentChatVisibleThreadsSelector';
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
import { currentAiChatThreadTitleComponentFamilyState } from '@/ai/states/currentAiChatThreadTitleComponentFamilyState';
import { hasInitializedAgentChatThreadsState } from '@/ai/states/hasInitializedAgentChatThreadsState';
import { hasTriggeredCreateForDraftState } from '@/ai/states/hasTriggeredCreateForDraftState';
import { sortChatThreadsByLastActivityDesc } from '@/ai/utils/sortChatThreadsByLastActivityDesc';
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { type FlatAgentChatThread } from '@/metadata-store/types/FlatAgentChatThread';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateCallbackState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
@@ -48,7 +48,9 @@ export const AgentChatThreadInitializationEffect = () => {
agentChatUsageComponentFamilyState,
);
const store = useStore();
const agentChatThreads = useAtomStateValue(agentChatThreadsSelector);
const agentChatVisibleThreads = useAtomStateValue(
agentChatVisibleThreadsSelector,
);
const storeEntry = useAtomValue(
metadataStoreState.atomFamily('agentChatThreads'),
);
@@ -63,17 +65,14 @@ export const AgentChatThreadInitializationEffect = () => {
client
.query({
query: GetChatThreadsDocument,
variables: { paging: { first: 500 } },
fetchPolicy: 'network-only',
})
.then((result) => {
if (!isDefined(result.data?.chatThreads?.edges)) {
if (!isDefined(result.data?.chatThreads)) {
return;
}
const threads = result.data.chatThreads.edges.map((edge) => edge.node);
replaceDraft('agentChatThreads', threads);
replaceDraft('agentChatThreads', result.data.chatThreads);
applyChanges();
});
}, [
@@ -104,9 +103,8 @@ export const AgentChatThreadInitializationEffect = () => {
setHasInitializedAgentChatThreads(true);
const sortedThreads = agentChatThreads.toSorted(
(a: FlatAgentChatThread, b: FlatAgentChatThread) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
const sortedThreads = sortChatThreadsByLastActivityDesc(
agentChatVisibleThreads,
);
if (sortedThreads.length > 0) {
@@ -152,7 +150,7 @@ export const AgentChatThreadInitializationEffect = () => {
);
}
}, [
agentChatThreads,
agentChatVisibleThreads,
currentAiChatThread,
hasAiSettingsPermission,
hasInitializedAgentChatThreads,
@@ -5,7 +5,7 @@ import { isDefined } from 'twenty-shared/utils';
import { AiChatSuggestedPrompts } from '@/ai/components/suggested-prompts/AiChatSuggestedPrompts';
import { AGENT_CHAT_NEW_THREAD_DRAFT_KEY } from '@/ai/states/agentChatDraftsByThreadIdState';
import { agentChatErrorComponentFamilyState } from '@/ai/states/agentChatErrorComponentFamilyState';
import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector';
import { agentChatHasMessageComponentSelector } from '@/ai/states/selectors/agentChatHasMessageComponentSelector';
import { agentChatMessagesLoadingState } from '@/ai/states/agentChatMessagesLoadingState';
import { agentChatThreadsLoadingState } from '@/ai/states/agentChatThreadsLoadingState';
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
@@ -3,8 +3,8 @@ import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { agentChatErrorComponentFamilyState } from '@/ai/states/agentChatErrorComponentFamilyState';
import { agentChatIsStreamingComponentFamilyState } from '@/ai/states/agentChatIsStreamingComponentFamilyState';
import { agentChatMessageComponentFamilySelector } from '@/ai/states/agentChatMessageComponentFamilySelector';
import { agentChatMessageIdsComponentSelector } from '@/ai/states/agentChatMessageIdsComponentSelector';
import { agentChatMessageComponentFamilySelector } from '@/ai/states/selectors/agentChatMessageComponentFamilySelector';
import { agentChatMessageIdsComponentSelector } from '@/ai/states/selectors/agentChatMessageIdsComponentSelector';
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
@@ -2,7 +2,7 @@ import { AiChatMessage } from '@/ai/components/AiChatMessage';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { agentChatErrorComponentFamilyState } from '@/ai/states/agentChatErrorComponentFamilyState';
import { agentChatIsStreamingComponentFamilyState } from '@/ai/states/agentChatIsStreamingComponentFamilyState';
import { agentChatLastMessageIdComponentSelector } from '@/ai/states/agentChatLastMessageIdComponentSelector';
import { agentChatLastMessageIdComponentSelector } from '@/ai/states/selectors/agentChatLastMessageIdComponentSelector';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -5,7 +5,7 @@ import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
import { AiChatAssistantMessageRenderer } from '@/ai/components/AiChatAssistantMessageRenderer';
import { AiChatErrorRenderer } from '@/ai/components/AiChatErrorRenderer';
import { agentChatMessageComponentFamilySelector } from '@/ai/states/agentChatMessageComponentFamilySelector';
import { agentChatMessageComponentFamilySelector } from '@/ai/states/selectors/agentChatMessageComponentFamilySelector';
import { type AiChatError } from '@/ai/types/AiChatError';
import { LightCopyIconButton } from '@/object-record/record-field/ui/components/LightCopyIconButton';
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
@@ -1,5 +1,5 @@
import { AiChatMessage } from '@/ai/components/AiChatMessage';
import { agentChatNonLastMessageIdsComponentSelector } from '@/ai/states/agentChatNonLastMessageIdsComponentSelector';
import { agentChatNonLastMessageIdsComponentSelector } from '@/ai/states/selectors/agentChatNonLastMessageIdsComponentSelector';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
export const AiChatNonLastMessageIdsList = () => {
@@ -1,4 +1,4 @@
import { agentChatIsScrolledToBottomSelector } from '@/ai/states/agentChatIsScrolledToBottomSelector';
import { agentChatIsScrolledToBottomSelector } from '@/ai/states/selectors/agentChatIsScrolledToBottomSelector';
import { scrollAiChatToBottom } from '@/ai/utils/scrollAiChatToBottom';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
@@ -3,7 +3,7 @@ import { themeCssVariables } from 'twenty-ui/theme-constants';
import { AiChatErrorRenderer } from '@/ai/components/AiChatErrorRenderer';
import { agentChatErrorComponentFamilyState } from '@/ai/states/agentChatErrorComponentFamilyState';
import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector';
import { agentChatHasMessageComponentSelector } from '@/ai/states/selectors/agentChatHasMessageComponentSelector';
import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState';
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
@@ -4,7 +4,7 @@ import { AiChatNonLastMessageIdsList } from '@/ai/components/AiChatNonLastMessag
import { AiChatScrollToBottomButton } from '@/ai/components/AiChatScrollToBottomButton';
import { AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect } from '@/ai/components/AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect';
import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId';
import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector';
import { agentChatHasMessageComponentSelector } from '@/ai/states/selectors/agentChatHasMessageComponentSelector';
import { agentChatIsInitialScrollPendingOnThreadChangeState } from '@/ai/states/agentChatIsInitialScrollPendingOnThreadChangeState';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
@@ -0,0 +1,54 @@
import { Trans, useLingui } from '@lingui/react/macro';
import { type AiChatThreadActionsSurface } from '@/ai/types/AiChatThreadActionsSurface';
import { useDeleteChatThread } from '@/ai/hooks/useDeleteChatThread';
import { aiChatThreadPendingDeleteFamilyState } from '@/ai/states/aiChatThreadPendingDeleteFamilyState';
import { getAiChatThreadDeleteModalId } from '@/ai/utils/getAiChatThreadDeleteModalId';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState';
type AiChatThreadDeleteConfirmationModalProps = {
surface: AiChatThreadActionsSurface;
};
export const AiChatThreadDeleteConfirmationModal = ({
surface,
}: AiChatThreadDeleteConfirmationModalProps) => {
const { t } = useLingui();
const { deleteChatThread } = useDeleteChatThread();
const aiChatThreadPendingDelete = useAtomFamilyStateValue(
aiChatThreadPendingDeleteFamilyState,
surface,
);
const setAiChatThreadPendingDelete = useSetAtomFamilyState(
aiChatThreadPendingDeleteFamilyState,
surface,
);
const modalInstanceId = getAiChatThreadDeleteModalId(surface);
const handleDelete = async () => {
if (aiChatThreadPendingDelete === null) return;
await deleteChatThread(aiChatThreadPendingDelete.threadId);
setAiChatThreadPendingDelete(null);
};
return (
<ConfirmationModal
modalInstanceId={modalInstanceId}
title={t`Delete chat`}
subtitle={
<Trans>
<strong>{aiChatThreadPendingDelete?.threadTitle ?? ''}</strong> and
all its messages will be removed.
</Trans>
}
onConfirmClick={handleDelete}
onClose={() => setAiChatThreadPendingDelete(null)}
confirmButtonText={t`Delete`}
confirmButtonAccent="danger"
/>
);
};
@@ -0,0 +1,51 @@
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { IconAdjustments } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { AiChatThreadFilterDropdownContent } from '@/ai/components/AiChatThreadFilterDropdownContent';
import { AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE } from '@/ai/constants/AiChatThreadFilterDropdownPage';
import { type AiChatThreadActionsSurface } from '@/ai/types/AiChatThreadActionsSurface';
import { type AiChatThreadFilterDropdownPage } from '@/ai/types/AiChatThreadFilterDropdownPage';
import { getAiChatThreadFilterDropdownId } from '@/ai/utils/getAiChatThreadFilterDropdownId';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
type AiChatThreadFilterDropdownProps = {
surface: AiChatThreadActionsSurface;
};
export const AiChatThreadFilterDropdown = ({
surface,
}: AiChatThreadFilterDropdownProps) => {
const { t } = useLingui();
const dropdownId = getAiChatThreadFilterDropdownId(surface);
const [page, setPage] = useState<AiChatThreadFilterDropdownPage>(
AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE.ROOT,
);
const goToRoot = () => setPage(AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE.ROOT);
return (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-end"
onClose={goToRoot}
clickableComponent={
<LightIconButton
aria-label={t`Filter chats`}
Icon={IconAdjustments}
accent="tertiary"
size="small"
/>
}
dropdownComponents={
<AiChatThreadFilterDropdownContent
page={page}
dropdownId={dropdownId}
onSelectPage={setPage}
onBack={goToRoot}
/>
}
/>
);
};
@@ -0,0 +1,37 @@
import { AiChatThreadFilterDropdownGroupByMenu } from '@/ai/components/AiChatThreadFilterDropdownGroupByMenu';
import { AiChatThreadFilterDropdownLastActivityMenu } from '@/ai/components/AiChatThreadFilterDropdownLastActivityMenu';
import { AiChatThreadFilterDropdownRootMenu } from '@/ai/components/AiChatThreadFilterDropdownRootMenu';
import { AiChatThreadFilterDropdownStatusMenu } from '@/ai/components/AiChatThreadFilterDropdownStatusMenu';
import { AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE } from '@/ai/constants/AiChatThreadFilterDropdownPage';
import { type AiChatThreadFilterDropdownPage } from '@/ai/types/AiChatThreadFilterDropdownPage';
type AiChatThreadFilterDropdownContentProps = {
page: AiChatThreadFilterDropdownPage;
dropdownId: string;
onSelectPage: (page: AiChatThreadFilterDropdownPage) => void;
onBack: () => void;
};
export const AiChatThreadFilterDropdownContent = ({
page,
dropdownId,
onSelectPage,
onBack,
}: AiChatThreadFilterDropdownContentProps) => {
switch (page) {
case AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE.STATUS:
return <AiChatThreadFilterDropdownStatusMenu onBack={onBack} />;
case AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE.GROUP_BY:
return <AiChatThreadFilterDropdownGroupByMenu onBack={onBack} />;
case AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE.LAST_ACTIVITY:
return <AiChatThreadFilterDropdownLastActivityMenu onBack={onBack} />;
case AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE.ROOT:
default:
return (
<AiChatThreadFilterDropdownRootMenu
dropdownId={dropdownId}
onSelectPage={onSelectPage}
/>
);
}
};
@@ -0,0 +1,60 @@
import { useLingui } from '@lingui/react/macro';
import { IconChevronLeft } from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { AGENT_CHAT_THREAD_GROUP_BY } from '@/ai/constants/AgentChatThreadGroupBy';
import { AGENT_CHAT_THREAD_GROUP_BY_LABELS } from '@/ai/constants/AgentChatThreadGroupByLabels';
import { agentChatThreadGroupByState } from '@/ai/states/agentChatThreadGroupByState';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
const AGENT_CHAT_THREAD_GROUP_BY_OPTIONS = [
AGENT_CHAT_THREAD_GROUP_BY.DATE,
AGENT_CHAT_THREAD_GROUP_BY.NONE,
] as const;
type AiChatThreadFilterDropdownGroupByMenuProps = {
onBack: () => void;
};
export const AiChatThreadFilterDropdownGroupByMenu = ({
onBack,
}: AiChatThreadFilterDropdownGroupByMenuProps) => {
const { t } = useLingui();
const { closeDropdown } = useCloseDropdown();
const [agentChatThreadGroupBy, setAgentChatThreadGroupBy] = useAtomState(
agentChatThreadGroupByState,
);
return (
<DropdownContent>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={onBack}
Icon={IconChevronLeft}
/>
}
>
{t`Group by`}
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
{AGENT_CHAT_THREAD_GROUP_BY_OPTIONS.map((option) => (
<MenuItemSelect
key={option}
text={t(AGENT_CHAT_THREAD_GROUP_BY_LABELS[option])}
selected={agentChatThreadGroupBy === option}
onClick={() => {
setAgentChatThreadGroupBy(option);
closeDropdown();
}}
/>
))}
</DropdownMenuItemsContainer>
</DropdownContent>
);
};
@@ -0,0 +1,64 @@
import { useLingui } from '@lingui/react/macro';
import { IconChevronLeft } from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER } from '@/ai/constants/AgentChatThreadLastActivityFilter';
import { AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER_LABELS } from '@/ai/constants/AgentChatThreadLastActivityFilterLabels';
import { agentChatThreadLastActivityFilterState } from '@/ai/states/agentChatThreadLastActivityFilterState';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
const AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER_OPTIONS = [
AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER.ONE_DAY,
AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER.THREE_DAYS,
AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER.SEVEN_DAYS,
AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER.THIRTY_DAYS,
AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER.ALL,
] as const;
type AiChatThreadFilterDropdownLastActivityMenuProps = {
onBack: () => void;
};
export const AiChatThreadFilterDropdownLastActivityMenu = ({
onBack,
}: AiChatThreadFilterDropdownLastActivityMenuProps) => {
const { t } = useLingui();
const { closeDropdown } = useCloseDropdown();
const [
agentChatThreadLastActivityFilter,
setAgentChatThreadLastActivityFilter,
] = useAtomState(agentChatThreadLastActivityFilterState);
return (
<DropdownContent>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={onBack}
Icon={IconChevronLeft}
/>
}
>
{t`Last activity`}
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
{AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER_OPTIONS.map((option) => (
<MenuItemSelect
key={option}
text={t(AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER_LABELS[option])}
selected={agentChatThreadLastActivityFilter === option}
onClick={() => {
setAgentChatThreadLastActivityFilter(option);
closeDropdown();
}}
/>
))}
</DropdownMenuItemsContainer>
</DropdownContent>
);
};
@@ -0,0 +1,119 @@
import { useLingui } from '@lingui/react/macro';
import {
IconClock,
IconLayoutList,
IconStatusChange,
IconTrash,
} from 'twenty-ui/display';
import { AGENT_CHAT_THREAD_FILTER_STATUS } from '@/ai/constants/AgentChatThreadFilterStatus';
import { AGENT_CHAT_THREAD_FILTER_STATUS_LABELS } from '@/ai/constants/AgentChatThreadFilterStatusLabels';
import { AGENT_CHAT_THREAD_GROUP_BY } from '@/ai/constants/AgentChatThreadGroupBy';
import { AGENT_CHAT_THREAD_GROUP_BY_LABELS } from '@/ai/constants/AgentChatThreadGroupByLabels';
import { AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER } from '@/ai/constants/AgentChatThreadLastActivityFilter';
import { AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER_LABELS } from '@/ai/constants/AgentChatThreadLastActivityFilterLabels';
import { AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE } from '@/ai/constants/AiChatThreadFilterDropdownPage';
import { type AiChatThreadFilterDropdownPage } from '@/ai/types/AiChatThreadFilterDropdownPage';
import { agentChatThreadFilterStatusState } from '@/ai/states/agentChatThreadFilterStatusState';
import { agentChatThreadGroupByState } from '@/ai/states/agentChatThreadGroupByState';
import { agentChatThreadLastActivityFilterState } from '@/ai/states/agentChatThreadLastActivityFilterState';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { MenuItem } from 'twenty-ui/navigation';
type AiChatThreadFilterDropdownRootMenuProps = {
dropdownId: string;
onSelectPage: (page: AiChatThreadFilterDropdownPage) => void;
};
export const AiChatThreadFilterDropdownRootMenu = ({
dropdownId,
onSelectPage,
}: AiChatThreadFilterDropdownRootMenuProps) => {
const { t } = useLingui();
const { closeDropdown } = useCloseDropdown();
const [agentChatThreadFilterStatus, setAgentChatThreadFilterStatus] =
useAtomState(agentChatThreadFilterStatusState);
const [agentChatThreadGroupBy, setAgentChatThreadGroupBy] = useAtomState(
agentChatThreadGroupByState,
);
const [
agentChatThreadLastActivityFilter,
setAgentChatThreadLastActivityFilter,
] = useAtomState(agentChatThreadLastActivityFilterState);
const isAtDefaults =
agentChatThreadFilterStatus === AGENT_CHAT_THREAD_FILTER_STATUS.ACTIVE &&
agentChatThreadGroupBy === AGENT_CHAT_THREAD_GROUP_BY.DATE &&
agentChatThreadLastActivityFilter ===
AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER.ALL;
const handleClearFilters = () => {
setAgentChatThreadFilterStatus(AGENT_CHAT_THREAD_FILTER_STATUS.ACTIVE);
setAgentChatThreadGroupBy(AGENT_CHAT_THREAD_GROUP_BY.DATE);
setAgentChatThreadLastActivityFilter(
AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER.ALL,
);
closeDropdown(dropdownId);
};
return (
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItem
LeftIcon={IconStatusChange}
text={t`Status`}
contextualText={t(
AGENT_CHAT_THREAD_FILTER_STATUS_LABELS[agentChatThreadFilterStatus],
)}
contextualTextPosition="right"
hasSubMenu
onClick={() =>
onSelectPage(AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE.STATUS)
}
/>
<MenuItem
LeftIcon={IconLayoutList}
text={t`Group by`}
contextualText={t(
AGENT_CHAT_THREAD_GROUP_BY_LABELS[agentChatThreadGroupBy],
)}
contextualTextPosition="right"
hasSubMenu
onClick={() =>
onSelectPage(AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE.GROUP_BY)
}
/>
<MenuItem
LeftIcon={IconClock}
text={t`Last activity`}
contextualText={t(
AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER_LABELS[
agentChatThreadLastActivityFilter
],
)}
contextualTextPosition="right"
hasSubMenu
onClick={() =>
onSelectPage(AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE.LAST_ACTIVITY)
}
/>
{!isAtDefaults && (
<>
<DropdownMenuSeparator />
<MenuItem
accent="danger"
LeftIcon={IconTrash}
text={t`Clear filters`}
onClick={handleClearFilters}
/>
</>
)}
</DropdownMenuItemsContainer>
</DropdownContent>
);
};
@@ -0,0 +1,60 @@
import { useLingui } from '@lingui/react/macro';
import { IconChevronLeft } from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { AGENT_CHAT_THREAD_FILTER_STATUS } from '@/ai/constants/AgentChatThreadFilterStatus';
import { AGENT_CHAT_THREAD_FILTER_STATUS_LABELS } from '@/ai/constants/AgentChatThreadFilterStatusLabels';
import { agentChatThreadFilterStatusState } from '@/ai/states/agentChatThreadFilterStatusState';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
const AGENT_CHAT_THREAD_FILTER_STATUS_OPTIONS = [
AGENT_CHAT_THREAD_FILTER_STATUS.ACTIVE,
AGENT_CHAT_THREAD_FILTER_STATUS.ARCHIVED,
AGENT_CHAT_THREAD_FILTER_STATUS.ALL,
] as const;
type AiChatThreadFilterDropdownStatusMenuProps = {
onBack: () => void;
};
export const AiChatThreadFilterDropdownStatusMenu = ({
onBack,
}: AiChatThreadFilterDropdownStatusMenuProps) => {
const { t } = useLingui();
const { closeDropdown } = useCloseDropdown();
const [agentChatThreadFilterStatus, setAgentChatThreadFilterStatus] =
useAtomState(agentChatThreadFilterStatusState);
return (
<DropdownContent>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={onBack}
Icon={IconChevronLeft}
/>
}
>
{t`Status`}
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
{AGENT_CHAT_THREAD_FILTER_STATUS_OPTIONS.map((option) => (
<MenuItemSelect
key={option}
text={t(AGENT_CHAT_THREAD_FILTER_STATUS_LABELS[option])}
selected={agentChatThreadFilterStatus === option}
onClick={() => {
setAgentChatThreadFilterStatus(option);
closeDropdown();
}}
/>
))}
</DropdownMenuItemsContainer>
</DropdownContent>
);
};
@@ -1,9 +1,9 @@
import { useAiChatThreadClick } from '@/ai/hooks/useAiChatThreadClick';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { IconSparkles } from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { type ReactNode } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { AiChatThreadListItem } from '@/ai/components/AiChatThreadListItem';
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
import { type AgentChatThread } from '~/generated-metadata/graphql';
const StyledThreadsList = styled.div`
@@ -16,91 +16,33 @@ const StyledDateGroup = styled.div`
margin-bottom: ${themeCssVariables.spacing[4]};
`;
const StyledDateHeader = styled.div`
color: ${themeCssVariables.font.color.light};
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.medium};
margin-bottom: ${themeCssVariables.spacing[1]};
`;
const StyledThreadItem = styled.div<{ isSelected?: boolean }>`
align-items: center;
border-left: 3px solid transparent;
border-radius: ${themeCssVariables.border.radius.sm};
cursor: pointer;
display: flex;
gap: ${themeCssVariables.spacing[2]};
margin-bottom: ${themeCssVariables.spacing[1]};
padding: ${themeCssVariables.spacing[1]} 1px;
position: relative;
right: 3px;
transition: all 0.2s ease;
width: calc(100% + 1px);
&:hover {
background: ${themeCssVariables.background.transparent.light};
}
`;
const StyledSparkleIcon = styled.div`
align-items: center;
background: ${themeCssVariables.background.transparent.blue};
border-radius: ${themeCssVariables.border.radius.sm};
display: flex;
justify-content: center;
padding: ${themeCssVariables.spacing[1]};
`;
const StyledThreadContent = styled.div`
flex: 1;
min-width: 0;
`;
const StyledThreadTitle = styled.div`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.md};
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
export const AiChatThreadGroup = ({
threads,
title,
}: {
type AiChatThreadGroupProps = {
alwaysShowRightIcon?: boolean;
rightIcon?: ReactNode;
threads: AgentChatThread[];
title: string;
}) => {
const { theme } = useContext(ThemeContext);
const { t } = useLingui();
const { handleThreadClick } = useAiChatThreadClick();
};
export const AiChatThreadGroup = ({
alwaysShowRightIcon = false,
rightIcon,
threads,
title,
}: AiChatThreadGroupProps) => {
if (threads.length === 0) {
return null;
}
return (
<StyledDateGroup>
<StyledDateHeader>{title}</StyledDateHeader>
<NavigationDrawerSectionTitle
label={title}
alwaysShowRightIcon={alwaysShowRightIcon}
rightIcon={rightIcon}
/>
<StyledThreadsList>
{threads.map((thread) => (
<StyledThreadItem
onClick={() => handleThreadClick(thread)}
key={thread.id}
>
<StyledSparkleIcon>
<IconSparkles
size={theme.icon.size.md}
color={theme.color.blue}
/>
</StyledSparkleIcon>
<StyledThreadContent>
<StyledThreadTitle>
{thread.title || t`Untitled`}
</StyledThreadTitle>
</StyledThreadContent>
</StyledThreadItem>
<AiChatThreadListItem key={thread.id} thread={thread} />
))}
</StyledThreadsList>
</StyledDateGroup>
@@ -0,0 +1,108 @@
import { useLingui } from '@lingui/react/macro';
import {
IconArchive,
IconArchiveOff,
IconDotsVertical,
IconPencil,
IconTrash,
} from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
import { type AiChatThreadActionsSurface } from '@/ai/types/AiChatThreadActionsSurface';
import { useChatThreadArchiveActions } from '@/ai/hooks/useChatThreadArchiveActions';
import { aiChatThreadPendingDeleteFamilyState } from '@/ai/states/aiChatThreadPendingDeleteFamilyState';
import { getAiChatThreadDeleteModalId } from '@/ai/utils/getAiChatThreadDeleteModalId';
import { getAiChatThreadItemMenuDropdownId } from '@/ai/utils/getAiChatThreadItemMenuDropdownId';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState';
type AiChatThreadItemMenuProps = {
threadId: string;
threadTitle: string;
isArchived: boolean;
surface: AiChatThreadActionsSurface;
onRenameRequested: () => void;
};
export const AiChatThreadItemMenu = ({
threadId,
threadTitle,
isArchived,
surface,
onRenameRequested,
}: AiChatThreadItemMenuProps) => {
const { t } = useLingui();
const dropdownId = getAiChatThreadItemMenuDropdownId(threadId, surface);
const { closeDropdown } = useCloseDropdown();
const { openModal } = useModal();
const { archiveChatThread, unarchiveChatThread } =
useChatThreadArchiveActions();
const setAiChatThreadPendingDelete = useSetAtomFamilyState(
aiChatThreadPendingDeleteFamilyState,
surface,
);
const handleRename = (event: React.MouseEvent) => {
event.stopPropagation();
closeDropdown(dropdownId);
onRenameRequested();
};
const handleArchive = async (event: React.MouseEvent) => {
event.stopPropagation();
closeDropdown(dropdownId);
if (isArchived) {
await unarchiveChatThread(threadId);
} else {
await archiveChatThread(threadId);
}
};
const handleDelete = (event: React.MouseEvent) => {
event.stopPropagation();
closeDropdown(dropdownId);
setAiChatThreadPendingDelete({ threadId, threadTitle });
openModal(getAiChatThreadDeleteModalId(surface));
};
return (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-end"
clickableComponent={
<LightIconButton
aria-label={t`Chat actions`}
Icon={IconDotsVertical}
accent="tertiary"
/>
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItem
text={t`Rename`}
LeftIcon={IconPencil}
onClick={handleRename}
/>
<MenuItem
text={isArchived ? t`Unarchive` : t`Archive`}
LeftIcon={isArchived ? IconArchiveOff : IconArchive}
onClick={handleArchive}
/>
<MenuItem
accent="danger"
text={t`Delete`}
LeftIcon={IconTrash}
onClick={handleDelete}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
);
};
@@ -0,0 +1,163 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { Key } from 'ts-key-enum';
import { IconArchive, IconSparkles } from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { AiChatThreadItemMenu } from '@/ai/components/AiChatThreadItemMenu';
import { AI_CHAT_THREAD_ACTIONS_SURFACE } from '@/ai/constants/AiChatThreadActionsSurface';
import { useAiChatThreadClick } from '@/ai/hooks/useAiChatThreadClick';
import { useAiChatThreadRename } from '@/ai/hooks/useAiChatThreadRename';
import { getAiChatThreadItemMenuDropdownId } from '@/ai/utils/getAiChatThreadItemMenuDropdownId';
import { TextInput } from '@/ui/input/components/TextInput';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { type AgentChatThread } from '~/generated-metadata/graphql';
const StyledThreadItem = styled.div`
align-items: center;
border-left: 3px solid transparent;
border-radius: ${themeCssVariables.border.radius.sm};
cursor: pointer;
display: flex;
gap: ${themeCssVariables.spacing[2]};
margin-bottom: ${themeCssVariables.spacing[1]};
padding: ${themeCssVariables.spacing[1]} 1px;
position: relative;
right: 3px;
transition: all 0.2s ease;
width: calc(100% + 1px);
&:hover {
background: ${themeCssVariables.background.transparent.light};
}
`;
const StyledThreadIcon = styled.div<{ $isArchived: boolean }>`
align-items: center;
background: ${({ $isArchived }) =>
$isArchived
? themeCssVariables.background.transparent.lighter
: themeCssVariables.background.transparent.blue};
border-radius: ${themeCssVariables.border.radius.sm};
color: ${({ $isArchived }) =>
$isArchived
? themeCssVariables.font.color.tertiary
: themeCssVariables.color.blue};
display: flex;
justify-content: center;
padding: ${themeCssVariables.spacing[1]};
`;
const StyledThreadContent = styled.div`
flex: 1;
min-width: 0;
`;
const StyledThreadTitle = styled.div`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.md};
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledMenuTrigger = styled.div<{ $isDropdownOpen: boolean }>`
opacity: ${({ $isDropdownOpen }) => ($isDropdownOpen ? 1 : 0)};
pointer-events: ${({ $isDropdownOpen }) =>
$isDropdownOpen ? 'auto' : 'none'};
position: absolute;
right: ${themeCssVariables.spacing[1]};
top: 50%;
transform: translateY(-50%);
transition: opacity 150ms;
${StyledThreadItem}:hover & {
opacity: 1;
pointer-events: auto;
}
`;
type AiChatThreadListItemProps = {
thread: AgentChatThread;
};
export const AiChatThreadListItem = ({ thread }: AiChatThreadListItemProps) => {
const { theme } = useContext(ThemeContext);
const { t } = useLingui();
const { handleThreadClick } = useAiChatThreadClick();
const {
isRenaming,
draftTitle,
setDraftTitle,
startRename,
cancelRename,
commitRename,
} = useAiChatThreadRename(thread);
const isArchived = Boolean(thread.deletedAt);
const ThreadIcon = isArchived ? IconArchive : IconSparkles;
const displayTitle = thread.title ?? t`Untitled`;
const itemMenuDropdownId = getAiChatThreadItemMenuDropdownId(
thread.id,
AI_CHAT_THREAD_ACTIONS_SURFACE.SIDE_PANEL,
);
const isDropdownOpen = useAtomComponentStateValue(
isDropdownOpenComponentState,
itemMenuDropdownId,
);
return (
<StyledThreadItem
onClick={() => {
if (!isRenaming) {
handleThreadClick(thread);
}
}}
>
<StyledThreadIcon $isArchived={isArchived}>
<ThreadIcon size={theme.icon.size.md} color="currentColor" />
</StyledThreadIcon>
<StyledThreadContent>
{isRenaming ? (
<TextInput
value={draftTitle}
onChange={setDraftTitle}
onClick={(event) => event.stopPropagation()}
onFocus={(event) => event.target.select()}
onBlur={() => commitRename(draftTitle)}
onKeyDown={(event) => {
if (event.key === Key.Enter) {
event.preventDefault();
void commitRename(draftTitle);
} else if (event.key === Key.Escape) {
event.preventDefault();
cancelRename();
}
}}
sizeVariant="sm"
fullWidth
autoFocus
aria-label={t`Rename chat`}
/>
) : (
<StyledThreadTitle>{displayTitle}</StyledThreadTitle>
)}
</StyledThreadContent>
<StyledMenuTrigger
$isDropdownOpen={isDropdownOpen}
onClick={(event) => event.stopPropagation()}
>
<AiChatThreadItemMenu
threadId={thread.id}
threadTitle={displayTitle}
isArchived={isArchived}
surface={AI_CHAT_THREAD_ACTIONS_SURFACE.SIDE_PANEL}
onRenameRequested={startRename}
/>
</StyledMenuTrigger>
</StyledThreadItem>
);
};
@@ -1,15 +1,22 @@
import { styled } from '@linaria/react';
import { AiChatThreadDeleteConfirmationModal } from '@/ai/components/AiChatThreadDeleteConfirmationModal';
import { AiChatThreadFilterDropdown } from '@/ai/components/AiChatThreadFilterDropdown';
import { AiChatThreadGroup } from '@/ai/components/AiChatThreadGroup';
import { AiChatThreadListItem } from '@/ai/components/AiChatThreadListItem';
import { AiChatThreadsListFocusEffect } from '@/ai/components/AiChatThreadsListFocusEffect';
import { AiChatSkeletonLoader } from '@/ai/components/internal/AiChatSkeletonLoader';
import { AGENT_CHAT_THREAD_GROUP_BY } from '@/ai/constants/AgentChatThreadGroupBy';
import { AI_CHAT_THREAD_ACTIONS_SURFACE } from '@/ai/constants/AiChatThreadActionsSurface';
import { useChatThreads } from '@/ai/hooks/useChatThreads';
import { useSwitchToNewAiChat } from '@/ai/hooks/useSwitchToNewAiChat';
import { agentChatThreadGroupByState } from '@/ai/states/agentChatThreadGroupByState';
import { groupThreadsByDate } from '@/ai/utils/groupThreadsByDate';
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { t } from '@lingui/core/macro';
import { Key } from 'ts-key-enum';
import { capitalize } from 'twenty-shared/utils';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { getOsControlSymbol } from 'twenty-ui/utilities';
@@ -28,11 +35,17 @@ const StyledThreadsContainer = styled.div`
padding: ${themeCssVariables.spacing[3]};
`;
const StyledFlatThreadList = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[1]};
`;
const StyledButtonsContainer = styled.div`
border-top: 1px solid ${themeCssVariables.border.color.medium};
display: flex;
justify-content: flex-end;
padding: ${themeCssVariables.spacing[2]} 10px;
padding: ${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[2]};
`;
export const AiChatThreadsList = () => {
@@ -48,25 +61,51 @@ export const AiChatThreadsList = () => {
});
const { threads, hasNextPage, loading, fetchMoreRef } = useChatThreads();
const groupedThreads = groupThreadsByDate(threads);
const agentChatThreadGroupBy = useAtomStateValue(agentChatThreadGroupByState);
if (loading && threads.length === 0) {
return <AiChatSkeletonLoader />;
}
const isGroupedByDate =
agentChatThreadGroupBy === AGENT_CHAT_THREAD_GROUP_BY.DATE;
const dateGroups = isGroupedByDate ? groupThreadsByDate(threads) : [];
const shouldRenderDateGroups = isGroupedByDate && dateGroups.length > 0;
const filterDropdown = (
<AiChatThreadFilterDropdown
surface={AI_CHAT_THREAD_ACTIONS_SURFACE.SIDE_PANEL}
/>
);
return (
<>
<AiChatThreadsListFocusEffect focusId={focusId} />
<StyledContainer>
<StyledThreadsContainer>
{Object.entries(groupedThreads).map(([title, threadsInGroup]) => (
<AiChatThreadGroup
key={title}
title={capitalize(title)}
threads={threadsInGroup}
/>
))}
{shouldRenderDateGroups ? (
dateGroups.map((dateGroup, index) => (
<AiChatThreadGroup
key={dateGroup.id}
title={dateGroup.title}
threads={dateGroup.threads}
rightIcon={index === 0 ? filterDropdown : undefined}
alwaysShowRightIcon={index === 0}
/>
))
) : (
<>
<NavigationDrawerSectionTitle
label={t`Recents`}
alwaysShowRightIcon
rightIcon={filterDropdown}
/>
<StyledFlatThreadList>
{threads.map((thread) => (
<AiChatThreadListItem key={thread.id} thread={thread} />
))}
</StyledFlatThreadList>
</>
)}
{hasNextPage ? (
<div ref={fetchMoreRef} style={{ minHeight: 1 }} />
) : null}
@@ -82,6 +121,9 @@ export const AiChatThreadsList = () => {
/>
</StyledButtonsContainer>
</StyledContainer>
<AiChatThreadDeleteConfirmationModal
surface={AI_CHAT_THREAD_ACTIONS_SURFACE.SIDE_PANEL}
/>
</>
);
};
@@ -2,14 +2,16 @@ import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { AiChatThreadDeleteConfirmationModal } from '@/ai/components/AiChatThreadDeleteConfirmationModal';
import { AiChatThreadFilterDropdown } from '@/ai/components/AiChatThreadFilterDropdown';
import { AiChatSkeletonLoader } from '@/ai/components/internal/AiChatSkeletonLoader';
import { NavigationDrawerAiChatThreadDateSection } from '@/ai/components/NavigationDrawerAiChatThreadDateSection';
import { NavigationDrawerAiChatThreadSection } from '@/ai/components/NavigationDrawerAiChatThreadSection';
import { AGENT_CHAT_THREAD_GROUP_BY } from '@/ai/constants/AgentChatThreadGroupBy';
import { AI_CHAT_THREAD_ACTIONS_SURFACE } from '@/ai/constants/AiChatThreadActionsSurface';
import { useAiChatThreadClick } from '@/ai/hooks/useAiChatThreadClick';
import { useChatThreads } from '@/ai/hooks/useChatThreads';
import { agentChatThreadGroupByState } from '@/ai/states/agentChatThreadGroupByState';
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
import { type DateGroupKey } from '@/ai/utils/dateGroupKey';
import { DATE_GROUP_KEYS } from '@/ai/utils/dateGroupKeys';
import { getDateGroupTitle } from '@/ai/utils/getDateGroupTitle';
import { groupThreadsByDate } from '@/ai/utils/groupThreadsByDate';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -22,11 +24,19 @@ const StyledContainer = styled.div`
const StyledThreadList = styled.div`
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: ${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[0]};
width: calc(100% - ${themeCssVariables.spacing[2]});
`;
const StyledSectionsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[3]};
`;
const StyledEmptyState = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.light};
@@ -42,6 +52,8 @@ const StyledFetchMoreTrigger = styled.div`
width: 100%;
`;
const AI_CHAT_RECENTS_NAVIGATION_SECTION_ID = 'AiChatRecents';
export const NavigationDrawerAiChatContent = () => {
const { t } = useLingui();
@@ -49,11 +61,10 @@ export const NavigationDrawerAiChatContent = () => {
const { handleThreadClick } = useAiChatThreadClick({
resetNavigationStack: true,
});
const agentChatThreadGroupBy = useAtomStateValue(agentChatThreadGroupByState);
const { threads, hasNextPage, loading, fetchMoreRef } = useChatThreads();
const groupedThreads = groupThreadsByDate(threads);
if (loading && threads.length === 0) {
return (
<StyledContainer>
@@ -62,33 +73,54 @@ export const NavigationDrawerAiChatContent = () => {
);
}
if (threads.length === 0) {
return (
<StyledContainer>
<StyledEmptyState>{t`No chat`}</StyledEmptyState>
</StyledContainer>
);
}
const isGroupedByDate =
agentChatThreadGroupBy === AGENT_CHAT_THREAD_GROUP_BY.DATE;
const dateGroups = isGroupedByDate ? groupThreadsByDate(threads) : [];
const shouldRenderDateGroups = isGroupedByDate && dateGroups.length > 0;
const filterDropdown = (
<AiChatThreadFilterDropdown
surface={AI_CHAT_THREAD_ACTIONS_SURFACE.NAV_DRAWER}
/>
);
return (
<StyledContainer>
<StyledThreadList>
{DATE_GROUP_KEYS.map((key: DateGroupKey) => {
const threadsInGroup = groupedThreads[key];
if (threadsInGroup.length === 0) return null;
return (
<NavigationDrawerAiChatThreadDateSection
key={key}
title={getDateGroupTitle(key)}
threads={threadsInGroup}
currentThreadId={currentAiChatThread}
onThreadClick={handleThreadClick}
/>
);
})}
{shouldRenderDateGroups ? (
<StyledSectionsContainer>
{dateGroups.map((dateGroup, index) => (
<NavigationDrawerAiChatThreadSection
key={dateGroup.id}
sectionId={`AiChatDateGroup:${dateGroup.id}`}
title={dateGroup.title}
threads={dateGroup.threads}
currentThreadId={currentAiChatThread}
onThreadClick={handleThreadClick}
rightIcon={index === 0 ? filterDropdown : undefined}
alwaysShowRightIcon={index === 0}
/>
))}
</StyledSectionsContainer>
) : (
<NavigationDrawerAiChatThreadSection
sectionId={AI_CHAT_RECENTS_NAVIGATION_SECTION_ID}
title={t`Recents`}
threads={threads}
currentThreadId={currentAiChatThread}
onThreadClick={handleThreadClick}
rightIcon={filterDropdown}
alwaysShowRightIcon
/>
)}
{threads.length === 0 ? (
<StyledEmptyState>{t`No chat`}</StyledEmptyState>
) : null}
{hasNextPage ? <StyledFetchMoreTrigger ref={fetchMoreRef} /> : null}
</StyledThreadList>
<AiChatThreadDeleteConfirmationModal
surface={AI_CHAT_THREAD_ACTIONS_SURFACE.NAV_DRAWER}
/>
</StyledContainer>
);
};
@@ -1,76 +0,0 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { IconComment } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { type AgentChatThread } from '~/generated-metadata/graphql';
import { beautifyPastDateRelativeToNowShort } from '~/utils/date-utils';
const StyledDateSection = styled.section`
margin-bottom: ${themeCssVariables.spacing[4]};
`;
const StyledThreadList = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing['0.5']};
`;
const StyledDateHeader = styled.div`
color: ${themeCssVariables.font.color.light};
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.medium};
margin-bottom: ${themeCssVariables.spacing[1]};
padding: ${themeCssVariables.spacing[0]} ${themeCssVariables.spacing[2]};
`;
const StyledThreadTimestamp = styled.span`
color: ${themeCssVariables.font.color.light};
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.regular};
padding-right: ${themeCssVariables.spacing['0.5']};
`;
export type NavigationDrawerAiChatThreadDateSectionProps = {
title: string;
threads: AgentChatThread[];
currentThreadId: string | null;
onThreadClick: (thread: AgentChatThread) => void;
};
export const NavigationDrawerAiChatThreadDateSection = ({
title,
threads,
currentThreadId,
onThreadClick,
}: NavigationDrawerAiChatThreadDateSectionProps) => {
const { t } = useLingui();
return (
<StyledDateSection>
<StyledDateHeader>{title}</StyledDateHeader>
<StyledThreadList>
{threads.map((thread) => {
const isActive = currentThreadId === thread.id;
const timestamp = beautifyPastDateRelativeToNowShort(
thread.updatedAt ?? thread.createdAt,
);
return (
<NavigationDrawerItem
key={thread.id}
label={thread.title || t`New chat`}
Icon={IconComment}
active={isActive}
onClick={() => onThreadClick(thread)}
alwaysShowRightOptions
rightOptions={
<StyledThreadTimestamp>{timestamp}</StyledThreadTimestamp>
}
/>
);
})}
</StyledThreadList>
</StyledDateSection>
);
};
@@ -0,0 +1,129 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { IconArchive, IconComment } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { AiChatThreadItemMenu } from '@/ai/components/AiChatThreadItemMenu';
import { AI_CHAT_THREAD_ACTIONS_SURFACE } from '@/ai/constants/AiChatThreadActionsSurface';
import { useAiChatThreadRename } from '@/ai/hooks/useAiChatThreadRename';
import { getAiChatThreadItemMenuDropdownId } from '@/ai/utils/getAiChatThreadItemMenuDropdownId';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { NavigationDrawerInput } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerInput';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { type AgentChatThread } from '~/generated-metadata/graphql';
import { beautifyPastDateRelativeToNowShort } from '~/utils/date-utils';
const StyledRightOptions = styled.div`
align-items: center;
display: flex;
height: ${themeCssVariables.spacing[6]};
justify-content: flex-end;
min-width: ${themeCssVariables.spacing[6]};
position: relative;
`;
const StyledTimestamp = styled.span<{ $isDropdownOpen: boolean }>`
color: ${themeCssVariables.font.color.light};
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.regular};
opacity: ${({ $isDropdownOpen }) => ($isDropdownOpen ? 0 : 1)};
transition: opacity 150ms;
.navigation-drawer-item:hover & {
opacity: 0;
}
`;
const StyledMenuTrigger = styled.div<{ $isDropdownOpen: boolean }>`
opacity: ${({ $isDropdownOpen }) => ($isDropdownOpen ? 1 : 0)};
pointer-events: ${({ $isDropdownOpen }) =>
$isDropdownOpen ? 'auto' : 'none'};
position: absolute;
right: 0;
top: 0;
transition: opacity 150ms;
.navigation-drawer-item:hover & {
opacity: 1;
pointer-events: auto;
}
`;
type NavigationDrawerAiChatThreadItemProps = {
thread: AgentChatThread;
isActive: boolean;
onClick: (thread: AgentChatThread) => void;
};
export const NavigationDrawerAiChatThreadItem = ({
thread,
isActive,
onClick,
}: NavigationDrawerAiChatThreadItemProps) => {
const { t } = useLingui();
const {
isRenaming,
draftTitle,
setDraftTitle,
startRename,
cancelRename,
commitRename,
} = useAiChatThreadRename(thread);
const isArchived = Boolean(thread.deletedAt);
const ThreadIcon = isArchived ? IconArchive : IconComment;
const displayLabel = thread.title || t`New chat`;
const timestamp = beautifyPastDateRelativeToNowShort(
thread.lastMessageAt ?? thread.updatedAt ?? thread.createdAt,
);
const itemMenuDropdownId = getAiChatThreadItemMenuDropdownId(
thread.id,
AI_CHAT_THREAD_ACTIONS_SURFACE.NAV_DRAWER,
);
const isDropdownOpen = useAtomComponentStateValue(
isDropdownOpenComponentState,
itemMenuDropdownId,
);
if (isRenaming) {
return (
<NavigationDrawerInput
Icon={ThreadIcon}
value={draftTitle}
onChange={setDraftTitle}
onSubmit={commitRename}
onCancel={cancelRename}
onClickOutside={(_event, value) => commitRename(value)}
placeholder={t`Chat name`}
/>
);
}
return (
<NavigationDrawerItem
label={displayLabel}
Icon={ThreadIcon}
active={isActive}
onClick={() => onClick(thread)}
variant={isArchived ? 'tertiary' : 'default'}
alwaysShowRightOptions
rightOptions={
<StyledRightOptions>
<StyledTimestamp $isDropdownOpen={isDropdownOpen}>
{timestamp}
</StyledTimestamp>
<StyledMenuTrigger $isDropdownOpen={isDropdownOpen}>
<AiChatThreadItemMenu
threadId={thread.id}
threadTitle={displayLabel}
isArchived={isArchived}
surface={AI_CHAT_THREAD_ACTIONS_SURFACE.NAV_DRAWER}
onRenameRequested={startRename}
/>
</StyledMenuTrigger>
</StyledRightOptions>
}
/>
);
};
@@ -0,0 +1,79 @@
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { NavigationDrawerAiChatThreadItem } from '@/ai/components/NavigationDrawerAiChatThreadItem';
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection';
import { type AgentChatThread } from '~/generated-metadata/graphql';
const StyledSection = styled.section`
display: flex;
flex-direction: column;
`;
const StyledThreadList = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing['0.5']};
padding-top: ${themeCssVariables.betweenSiblingsGap};
`;
export type NavigationDrawerAiChatThreadSectionProps = {
sectionId: string;
title: string;
threads: AgentChatThread[];
currentThreadId: string | null;
onThreadClick: (thread: AgentChatThread) => void;
rightIcon?: ReactNode;
alwaysShowRightIcon?: boolean;
};
export const NavigationDrawerAiChatThreadSection = ({
sectionId,
title,
threads,
currentThreadId,
onThreadClick,
rightIcon,
alwaysShowRightIcon = false,
}: NavigationDrawerAiChatThreadSectionProps) => {
const { isNavigationSectionOpen, toggleNavigationSection } =
useNavigationSection(sectionId);
return (
<StyledSection>
<NavigationDrawerAnimatedCollapseWrapper>
<NavigationDrawerSectionTitle
label={title}
onClick={toggleNavigationSection}
alwaysShowRightIcon={alwaysShowRightIcon}
isOpen={isNavigationSectionOpen}
rightIcon={rightIcon}
/>
</NavigationDrawerAnimatedCollapseWrapper>
{threads.length > 0 ? (
<AnimatedExpandableContainer
isExpanded={isNavigationSectionOpen}
dimension="height"
mode="fit-content"
containAnimation
initial={false}
>
<StyledThreadList>
{threads.map((thread) => (
<NavigationDrawerAiChatThreadItem
key={thread.id}
thread={thread}
isActive={currentThreadId === thread.id}
onClick={onThreadClick}
/>
))}
</StyledThreadList>
</AnimatedExpandableContainer>
) : null}
</StyledSection>
);
};
@@ -10,7 +10,7 @@ import { ComponentDecorator } from 'twenty-ui/testing';
import { AiChatMessage } from '@/ai/components/AiChatMessage';
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { agentChatMessageComponentFamilyState } from '@/ai/states/agentChatMessageComponentFamilyState';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
@@ -8,7 +8,7 @@ import { ProgressBar } from 'twenty-ui/feedback';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { ContextUsageProgressRing } from '@/ai/components/internal/ContextUsageProgressRing';
import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector';
import { agentChatHasMessageComponentSelector } from '@/ai/states/selectors/agentChatHasMessageComponentSelector';
import {
agentChatUsageComponentFamilyState,
type AgentChatLastMessageUsage,
@@ -6,7 +6,7 @@ import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { AGENT_CHAT_NEW_THREAD_DRAFT_KEY } from '@/ai/states/agentChatDraftsByThreadIdState';
import { agentChatMessagesLoadingState } from '@/ai/states/agentChatMessagesLoadingState';
import { agentChatThreadsLoadingState } from '@/ai/states/agentChatThreadsLoadingState';
import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector';
import { agentChatHasMessageComponentSelector } from '@/ai/states/selectors/agentChatHasMessageComponentSelector';
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
import { skipMessagesSkeletonUntilLoadedState } from '@/ai/states/skipMessagesSkeletonUntilLoadedState';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
@@ -1,5 +1,5 @@
import { AGENT_CHAT_STOP_EVENT_NAME } from '@/ai/constants/AgentChatStopEventName';
import { agentChatInputIsEmptySelector } from '@/ai/states/agentChatInputIsEmptySelector';
import { agentChatInputIsEmptySelector } from '@/ai/states/selectors/agentChatInputIsEmptySelector';
import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState';
import { agentChatIsStreamingComponentFamilyState } from '@/ai/states/agentChatIsStreamingComponentFamilyState';
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
@@ -0,0 +1,5 @@
export const AGENT_CHAT_THREAD_FILTER_STATUS = {
ACTIVE: 'active',
ARCHIVED: 'archived',
ALL: 'all',
} as const;
@@ -0,0 +1,13 @@
import { msg } from '@lingui/core/macro';
import { type MessageDescriptor } from '@lingui/core';
import { type AgentChatThreadFilterStatus } from '@/ai/types/AgentChatThreadFilterStatus';
export const AGENT_CHAT_THREAD_FILTER_STATUS_LABELS: Record<
AgentChatThreadFilterStatus,
MessageDescriptor
> = {
active: msg`Active`,
archived: msg`Archived`,
all: msg`All`,
};
@@ -0,0 +1,4 @@
export const AGENT_CHAT_THREAD_GROUP_BY = {
DATE: 'date',
NONE: 'none',
} as const;
@@ -0,0 +1,12 @@
import { msg } from '@lingui/core/macro';
import { type MessageDescriptor } from '@lingui/core';
import { type AgentChatThreadGroupBy } from '@/ai/types/AgentChatThreadGroupBy';
export const AGENT_CHAT_THREAD_GROUP_BY_LABELS: Record<
AgentChatThreadGroupBy,
MessageDescriptor
> = {
date: msg`Date`,
none: msg`None`,
};
@@ -0,0 +1,7 @@
export const AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER = {
ALL: 'all',
ONE_DAY: '1d',
THREE_DAYS: '3d',
SEVEN_DAYS: '7d',
THIRTY_DAYS: '30d',
} as const;
@@ -0,0 +1,12 @@
import { type AgentChatThreadLastActivityFilter } from '@/ai/types/AgentChatThreadLastActivityFilter';
export const AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER_DAYS: Record<
AgentChatThreadLastActivityFilter,
number | null
> = {
all: null,
'1d': 1,
'3d': 3,
'7d': 7,
'30d': 30,
};
@@ -0,0 +1,15 @@
import { msg } from '@lingui/core/macro';
import { type MessageDescriptor } from '@lingui/core';
import { type AgentChatThreadLastActivityFilter } from '@/ai/types/AgentChatThreadLastActivityFilter';
export const AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER_LABELS: Record<
AgentChatThreadLastActivityFilter,
MessageDescriptor
> = {
all: msg`All`,
'1d': msg`1d`,
'3d': msg`3d`,
'7d': msg`7d`,
'30d': msg`30d`,
};
@@ -0,0 +1,4 @@
export const AI_CHAT_THREAD_ACTIONS_SURFACE = {
SIDE_PANEL: 'side-panel',
NAV_DRAWER: 'nav-drawer',
} as const;
@@ -0,0 +1,6 @@
export const AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE = {
ROOT: 'root',
STATUS: 'status',
GROUP_BY: 'groupBy',
LAST_ACTIVITY: 'lastActivity',
} as const;
@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const ARCHIVE_CHAT_THREAD = gql`
mutation ArchiveChatThread($id: UUID!) {
archiveChatThread(id: $id) {
id
deletedAt
updatedAt
}
}
`;
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const DELETE_CHAT_THREAD = gql`
mutation DeleteChatThread($id: UUID!) {
deleteChatThread(id: $id)
}
`;
@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const RENAME_CHAT_THREAD = gql`
mutation RenameChatThread($id: UUID!, $title: String!) {
renameChatThread(id: $id, title: $title) {
id
title
updatedAt
}
}
`;
@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const UNARCHIVE_CHAT_THREAD = gql`
mutation UnarchiveChatThread($id: UUID!) {
unarchiveChatThread(id: $id) {
id
deletedAt
updatedAt
}
}
`;
@@ -1,27 +1,20 @@
import { gql } from '@apollo/client';
export const GET_CHAT_THREADS = gql`
query GetChatThreads($paging: CursorPaging) {
chatThreads(paging: $paging) {
edges {
node {
id
title
totalInputTokens
totalOutputTokens
contextWindowTokens
conversationSize
totalInputCredits
totalOutputCredits
createdAt
updatedAt
}
cursor
}
pageInfo {
endCursor
hasNextPage
}
query GetChatThreads {
chatThreads {
id
title
totalInputTokens
totalOutputTokens
contextWindowTokens
conversationSize
totalInputCredits
totalOutputCredits
deletedAt
lastMessageAt
createdAt
updatedAt
}
}
`;
@@ -0,0 +1,167 @@
import { act, renderHook } from '@testing-library/react';
import { useAiChatThreadRename } from '@/ai/hooks/useAiChatThreadRename';
import { useRenameChatThread } from '@/ai/hooks/useRenameChatThread';
import { type AgentChatThread } from '~/generated-metadata/graphql';
jest.mock('@/ai/hooks/useRenameChatThread');
const buildThread = (
overrides: Partial<AgentChatThread> = {},
): AgentChatThread =>
({
id: 'thread-1',
title: 'Existing title',
createdAt: '2026-04-01T00:00:00.000Z',
updatedAt: '2026-04-01T00:00:00.000Z',
totalInputTokens: 0,
totalOutputTokens: 0,
contextWindowTokens: null,
conversationSize: 0,
totalInputCredits: 0,
totalOutputCredits: 0,
...overrides,
}) as AgentChatThread;
describe('useAiChatThreadRename', () => {
const renameChatThread = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
renameChatThread.mockResolvedValue(true);
(useRenameChatThread as jest.Mock).mockReturnValue({ renameChatThread });
});
it('starts in non-renaming state with the current thread title as draft', () => {
const { result } = renderHook(() =>
useAiChatThreadRename(buildThread({ title: 'Existing title' })),
);
expect(result.current.isRenaming).toBe(false);
expect(result.current.draftTitle).toBe('Existing title');
});
it('falls back to empty string when the thread title is null', () => {
const { result } = renderHook(() =>
useAiChatThreadRename(buildThread({ title: null })),
);
expect(result.current.draftTitle).toBe('');
});
it('enters renaming mode and re-seeds draft from current title on startRename', () => {
const { result } = renderHook(() =>
useAiChatThreadRename(buildThread({ title: 'Existing title' })),
);
act(() => {
result.current.setDraftTitle('Stale draft');
});
act(() => {
result.current.startRename();
});
expect(result.current.isRenaming).toBe(true);
expect(result.current.draftTitle).toBe('Existing title');
});
it('exits renaming mode and resets the draft on cancelRename', () => {
const { result } = renderHook(() =>
useAiChatThreadRename(buildThread({ title: 'Existing title' })),
);
act(() => {
result.current.startRename();
result.current.setDraftTitle('Edited draft');
});
act(() => {
result.current.cancelRename();
});
expect(result.current.isRenaming).toBe(false);
expect(result.current.draftTitle).toBe('Existing title');
});
it('skips renaming when committed title is empty after trim', async () => {
const { result } = renderHook(() =>
useAiChatThreadRename(buildThread({ title: 'Existing title' })),
);
await act(async () => {
await result.current.commitRename(' ');
});
expect(renameChatThread).not.toHaveBeenCalled();
expect(result.current.isRenaming).toBe(false);
});
it('skips renaming when committed title equals the current title', async () => {
const { result } = renderHook(() =>
useAiChatThreadRename(buildThread({ title: 'Existing title' })),
);
await act(async () => {
await result.current.commitRename('Existing title');
});
expect(renameChatThread).not.toHaveBeenCalled();
});
it('trims surrounding whitespace before comparing to the current title', async () => {
const { result } = renderHook(() =>
useAiChatThreadRename(buildThread({ title: 'Existing title' })),
);
await act(async () => {
await result.current.commitRename(' Existing title ');
});
expect(renameChatThread).not.toHaveBeenCalled();
});
it('renames with the trimmed title when it differs from the current one', async () => {
const { result } = renderHook(() =>
useAiChatThreadRename(
buildThread({ id: 'thread-7', title: 'Old title' }),
),
);
await act(async () => {
await result.current.commitRename(' New title ');
});
expect(renameChatThread).toHaveBeenCalledWith('thread-7', 'New title');
expect(result.current.isRenaming).toBe(false);
});
it('keeps renaming mode open when the rename mutation reports failure', async () => {
renameChatThread.mockResolvedValueOnce(false);
const { result } = renderHook(() =>
useAiChatThreadRename(buildThread({ id: 'thread-fail', title: 'Old' })),
);
act(() => {
result.current.startRename();
});
await act(async () => {
await result.current.commitRename('New title');
});
expect(renameChatThread).toHaveBeenCalledWith('thread-fail', 'New title');
expect(result.current.isRenaming).toBe(true);
});
it('treats a null current title as empty when comparing against committed input', async () => {
const { result } = renderHook(() =>
useAiChatThreadRename(buildThread({ id: 'thread-9', title: null })),
);
await act(async () => {
await result.current.commitRename('First name');
});
expect(renameChatThread).toHaveBeenCalledWith('thread-9', 'First name');
});
});
@@ -13,20 +13,22 @@ import { AGENT_CHAT_SEND_MESSAGE_EVENT_NAME } from '@/ai/constants/AgentChatSend
import { AGENT_CHAT_STOP_EVENT_NAME } from '@/ai/constants/AgentChatStopEventName';
import { SEND_CHAT_MESSAGE } from '@/ai/graphql/mutations/sendChatMessage';
import { STOP_AGENT_CHAT_STREAM } from '@/ai/graphql/mutations/stopAgentChatStream';
import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId';
import { useGetBrowsingContext } from '@/ai/hooks/useBrowsingContext';
import { useOptimisticallyUnarchiveOnSend } from '@/ai/hooks/useOptimisticallyUnarchiveOnSend';
import {
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
agentChatDraftsByThreadIdState,
} from '@/ai/states/agentChatDraftsByThreadIdState';
import { agentChatErrorComponentFamilyState } from '@/ai/states/agentChatErrorComponentFamilyState';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { agentChatSelectedFilesState } from '@/ai/states/agentChatSelectedFilesState';
import { agentChatUploadedFilesState } from '@/ai/states/agentChatUploadedFilesState';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
import { useGetBrowsingContext } from '@/ai/hooks/useBrowsingContext';
import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId';
import { useListenToBrowserEvent } from '@/browser-event/hooks/useListenToBrowserEvent';
import { dispatchBrowserEvent } from '@/browser-event/utils/dispatchBrowserEvent';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
@@ -36,7 +38,9 @@ export const useAgentChat = (
) => {
const { modelIdForRequest } = useAgentChatModelId();
const { getBrowsingContext } = useGetBrowsingContext();
const { applyOptimisticUnarchive } = useOptimisticallyUnarchiveOnSend();
const apolloClient = useApolloClient();
const { enqueueErrorSnackBar } = useSnackBar();
const setCurrentAiChatThread = useSetAtomState(currentAiChatThreadState);
const store = useStore();
@@ -94,6 +98,11 @@ export const useAgentChat = (
const browsingContext = getBrowsingContext();
const messageId = v4();
const optimisticMessageCreatedAt = new Date().toISOString();
const rollbackOptimisticUnarchive = applyOptimisticUnarchive(
threadId,
optimisticMessageCreatedAt,
);
const optimisticUserMessage: ExtendedUIMessage = {
id: messageId,
@@ -103,7 +112,7 @@ export const useAgentChat = (
...agentChatUploadedFiles,
],
metadata: {
createdAt: new Date().toISOString(),
createdAt: optimisticMessageCreatedAt,
},
status: 'sent',
};
@@ -167,6 +176,8 @@ export const useAgentChat = (
const restoredDraftKey =
draftKey === AGENT_CHAT_NEW_THREAD_DRAFT_KEY ? threadId : draftKey;
rollbackOptimisticUnarchive?.();
setAgentChatInput(contentToSend);
setAgentChatDraftsByThreadId((prev) => ({
...prev,
@@ -213,6 +224,7 @@ export const useAgentChat = (
modelIdForRequest,
setCurrentAiChatThread,
apolloClient,
applyOptimisticUnarchive,
]);
useListenToBrowserEvent({
@@ -227,13 +239,17 @@ export const useAgentChat = (
return;
}
apolloClient
.mutate({
try {
await apolloClient.mutate({
mutation: STOP_AGENT_CHAT_STREAM,
variables: { threadId },
})
.catch(() => {});
}, [store, apolloClient]);
});
} catch (error) {
enqueueErrorSnackBar({
apolloError: CombinedGraphQLErrors.is(error) ? error : undefined,
});
}
}, [store, apolloClient, enqueueErrorSnackBar]);
useListenToBrowserEvent({
eventName: AGENT_CHAT_STOP_EVENT_NAME,
@@ -0,0 +1,45 @@
import { useState } from 'react';
import { useRenameChatThread } from '@/ai/hooks/useRenameChatThread';
import { type AgentChatThread } from '~/generated-metadata/graphql';
export const useAiChatThreadRename = (thread: AgentChatThread) => {
const { renameChatThread } = useRenameChatThread();
const [isRenaming, setIsRenaming] = useState(false);
const [draftTitle, setDraftTitle] = useState(thread.title ?? '');
const startRename = () => {
setDraftTitle(thread.title ?? '');
setIsRenaming(true);
};
const cancelRename = () => {
setIsRenaming(false);
setDraftTitle(thread.title ?? '');
};
const commitRename = async (nextTitle: string) => {
const trimmed = nextTitle.trim();
if (trimmed.length === 0 || trimmed === (thread.title ?? '')) {
setIsRenaming(false);
return;
}
const succeeded = await renameChatThread(thread.id, trimmed);
if (succeeded) {
setIsRenaming(false);
}
};
return {
isRenaming,
draftTitle,
setDraftTitle,
startRename,
cancelRename,
commitRename,
};
};
@@ -0,0 +1,17 @@
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
import { type FlatAgentChatThread } from '@/metadata-store/types/FlatAgentChatThread';
type AgentChatThreadDraftUpdate = Partial<FlatAgentChatThread> & {
id: string;
};
export const useApplyAgentChatThreadUpdate = () => {
const { updateInDraft, applyChanges } = useUpdateMetadataStoreDraft();
const applyAgentChatThreadUpdate = (update: AgentChatThreadDraftUpdate) => {
updateInDraft('agentChatThreads', [update as FlatAgentChatThread]);
applyChanges();
};
return { applyAgentChatThreadUpdate };
};
@@ -0,0 +1,55 @@
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { useMutation } from '@apollo/client/react';
import { useApplyAgentChatThreadUpdate } from '@/ai/hooks/useApplyAgentChatThreadUpdate';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import {
ArchiveChatThreadDocument,
UnarchiveChatThreadDocument,
} from '~/generated-metadata/graphql';
export const useChatThreadArchiveActions = () => {
const { applyAgentChatThreadUpdate } = useApplyAgentChatThreadUpdate();
const { enqueueErrorSnackBar } = useSnackBar();
const [archiveMutation] = useMutation(ArchiveChatThreadDocument);
const [unarchiveMutation] = useMutation(UnarchiveChatThreadDocument);
const archiveChatThread = async (id: string) => {
try {
const { data } = await archiveMutation({ variables: { id } });
if (data?.archiveChatThread) {
applyAgentChatThreadUpdate({
id: data.archiveChatThread.id,
deletedAt: data.archiveChatThread.deletedAt ?? null,
updatedAt: data.archiveChatThread.updatedAt,
});
}
} catch (error) {
enqueueErrorSnackBar({
apolloError: CombinedGraphQLErrors.is(error) ? error : undefined,
});
}
};
const unarchiveChatThread = async (id: string) => {
try {
const { data } = await unarchiveMutation({ variables: { id } });
if (data?.unarchiveChatThread) {
applyAgentChatThreadUpdate({
id: data.unarchiveChatThread.id,
deletedAt: data.unarchiveChatThread.deletedAt ?? null,
updatedAt: data.unarchiveChatThread.updatedAt,
});
}
} catch (error) {
enqueueErrorSnackBar({
apolloError: CombinedGraphQLErrors.is(error) ? error : undefined,
});
}
};
return { archiveChatThread, unarchiveChatThread };
};
@@ -1,27 +1,20 @@
import { useAtomValue } from 'jotai';
import { useMemo } from 'react';
import { agentChatThreadsSelector } from '@/ai/states/agentChatThreadsSelector';
import { agentChatVisibleThreadsSelector } from '@/ai/states/selectors/agentChatVisibleThreadsSelector';
import { sortChatThreadsByLastActivityDesc } from '@/ai/utils/sortChatThreadsByLastActivityDesc';
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const useChatThreads = () => {
const agentChatThreads = useAtomStateValue(agentChatThreadsSelector);
const agentChatVisibleThreads = useAtomStateValue(
agentChatVisibleThreadsSelector,
);
const storeEntry = useAtomValue(
metadataStoreState.atomFamily('agentChatThreads'),
);
const threads = useMemo(
() =>
[...agentChatThreads].sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
),
[agentChatThreads],
);
return {
threads,
threads: sortChatThreadsByLastActivityDesc(agentChatVisibleThreads),
hasNextPage: false,
loading: storeEntry.status === 'empty',
fetchMoreRef: undefined,
@@ -0,0 +1,66 @@
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { useMutation } from '@apollo/client/react';
import { useStore } from 'jotai';
import {
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
agentChatDraftsByThreadIdState,
} from '@/ai/states/agentChatDraftsByThreadIdState';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { agentChatVisibleThreadsSelector } from '@/ai/states/selectors/agentChatVisibleThreadsSelector';
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
import { sortChatThreadsByLastActivityDesc } from '@/ai/utils/sortChatThreadsByLastActivityDesc';
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { DeleteChatThreadDocument } from '~/generated-metadata/graphql';
export const useDeleteChatThread = () => {
const { removeFromDraft, applyChanges } = useUpdateMetadataStoreDraft();
const { enqueueErrorSnackBar } = useSnackBar();
const setCurrentAiChatThread = useSetAtomState(currentAiChatThreadState);
const setAgentChatInput = useSetAtomState(agentChatInputState);
const store = useStore();
const [deleteMutation] = useMutation(DeleteChatThreadDocument);
const deleteChatThread = async (id: string) => {
try {
await deleteMutation({ variables: { id } });
removeFromDraft({ key: 'agentChatThreads', itemIds: [id] });
applyChanges();
const isCurrent = store.get(currentAiChatThreadState.atom) === id;
if (!isCurrent) {
return;
}
const remaining = sortChatThreadsByLastActivityDesc(
store
.get(agentChatVisibleThreadsSelector.atom)
.filter((thread) => thread.id !== id),
);
const draftsByThreadId = store.get(agentChatDraftsByThreadIdState.atom);
if (remaining.length > 0) {
const nextThreadId = remaining[0].id;
setCurrentAiChatThread(nextThreadId);
setAgentChatInput(draftsByThreadId[nextThreadId] ?? '');
} else {
setCurrentAiChatThread(AGENT_CHAT_NEW_THREAD_DRAFT_KEY);
setAgentChatInput(
draftsByThreadId[AGENT_CHAT_NEW_THREAD_DRAFT_KEY] ?? '',
);
}
} catch (error) {
enqueueErrorSnackBar({
apolloError: CombinedGraphQLErrors.is(error) ? error : undefined,
});
}
};
return { deleteChatThread };
};
@@ -0,0 +1,47 @@
import { useStore } from 'jotai';
import { useApplyAgentChatThreadUpdate } from '@/ai/hooks/useApplyAgentChatThreadUpdate';
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { type FlatAgentChatThread } from '@/metadata-store/types/FlatAgentChatThread';
export const useOptimisticallyUnarchiveOnSend = () => {
const { applyAgentChatThreadUpdate } = useApplyAgentChatThreadUpdate();
const store = useStore();
const applyOptimisticUnarchive = (
threadId: string,
optimisticUpdatedAt: string,
): (() => void) | null => {
const entry = store.get(metadataStoreState.atomFamily('agentChatThreads'));
const threads = (
entry.status === 'draft-pending' ? entry.draft : entry.current
) as FlatAgentChatThread[];
const thread = threads.find((t) => t.id === threadId);
if (!thread?.deletedAt) {
return null;
}
const previousDeletedAt = thread.deletedAt;
const previousUpdatedAt = thread.updatedAt;
const previousLastMessageAt = thread.lastMessageAt;
applyAgentChatThreadUpdate({
id: threadId,
deletedAt: null,
updatedAt: optimisticUpdatedAt,
lastMessageAt: optimisticUpdatedAt,
});
return () => {
applyAgentChatThreadUpdate({
id: threadId,
deletedAt: previousDeletedAt,
updatedAt: previousUpdatedAt,
lastMessageAt: previousLastMessageAt,
});
};
};
return { applyOptimisticUnarchive };
};
@@ -0,0 +1,44 @@
import { useMutation } from '@apollo/client/react';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { useApplyAgentChatThreadUpdate } from '@/ai/hooks/useApplyAgentChatThreadUpdate';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { RenameChatThreadDocument } from '~/generated-metadata/graphql';
export const useRenameChatThread = () => {
const { applyAgentChatThreadUpdate } = useApplyAgentChatThreadUpdate();
const { enqueueErrorSnackBar } = useSnackBar();
const [renameChatThreadMutation] = useMutation(RenameChatThreadDocument);
const renameChatThread = async (
id: string,
title: string,
): Promise<boolean> => {
try {
const { data } = await renameChatThreadMutation({
variables: { id, title },
});
if (!data?.renameChatThread) {
return false;
}
applyAgentChatThreadUpdate({
id: data.renameChatThread.id,
title: data.renameChatThread.title ?? null,
updatedAt: data.renameChatThread.updatedAt,
});
return true;
} catch (error) {
enqueueErrorSnackBar({
apolloError: CombinedGraphQLErrors.is(error) ? error : undefined,
});
return false;
}
};
return { renameChatThread };
};
@@ -1,4 +1,4 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
import { type AiChatError } from '@/ai/types/AiChatError';
import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilyState';
@@ -1,4 +1,4 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilyState';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
@@ -1,4 +1,4 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilyState';
export const agentChatFirstLiveSeqComponentFamilyState =
@@ -1,6 +1,6 @@
import { type AgentChatSubscriptionEvent } from 'twenty-shared/ai';
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilyState';
export const agentChatHandleEventCallbackComponentFamilyState =
@@ -1,4 +1,4 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilyState';
export const agentChatIsStreamingComponentFamilyState =
@@ -1,4 +1,4 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilyState';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
@@ -1,4 +1,4 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilyState';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
@@ -1,4 +1,4 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
@@ -1,4 +1,4 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilyState';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
@@ -0,0 +1,11 @@
import { AGENT_CHAT_THREAD_FILTER_STATUS } from '@/ai/constants/AgentChatThreadFilterStatus';
import { type AgentChatThreadFilterStatus } from '@/ai/types/AgentChatThreadFilterStatus';
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const agentChatThreadFilterStatusState =
createAtomState<AgentChatThreadFilterStatus>({
key: 'agentChatThreadFilterStatusState',
defaultValue: AGENT_CHAT_THREAD_FILTER_STATUS.ACTIVE,
useLocalStorage: true,
localStorageOptions: { getOnInit: true },
});
@@ -0,0 +1,11 @@
import { AGENT_CHAT_THREAD_GROUP_BY } from '@/ai/constants/AgentChatThreadGroupBy';
import { type AgentChatThreadGroupBy } from '@/ai/types/AgentChatThreadGroupBy';
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const agentChatThreadGroupByState =
createAtomState<AgentChatThreadGroupBy>({
key: 'agentChatThreadGroupByState',
defaultValue: AGENT_CHAT_THREAD_GROUP_BY.DATE,
useLocalStorage: true,
localStorageOptions: { getOnInit: true },
});
@@ -0,0 +1,11 @@
import { AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER } from '@/ai/constants/AgentChatThreadLastActivityFilter';
import { type AgentChatThreadLastActivityFilter } from '@/ai/types/AgentChatThreadLastActivityFilter';
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const agentChatThreadLastActivityFilterState =
createAtomState<AgentChatThreadLastActivityFilter>({
key: 'agentChatThreadLastActivityFilterState',
defaultValue: AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER.ALL,
useLocalStorage: true,
localStorageOptions: { getOnInit: true },
});
@@ -1,14 +0,0 @@
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { type FlatAgentChatThread } from '@/metadata-store/types/FlatAgentChatThread';
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
export const agentChatThreadsSelector = createAtomSelector<
FlatAgentChatThread[]
>({
key: 'agentChatThreadsSelector',
get: ({ get }) => {
const storeItem = get(metadataStoreState, 'agentChatThreads');
return storeItem.current as FlatAgentChatThread[];
},
});
@@ -1,4 +1,4 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilyState';
export type AgentChatLastMessageUsage = {
@@ -0,0 +1,15 @@
import { type AiChatThreadActionsSurface } from '@/ai/types/AiChatThreadActionsSurface';
import { createAtomFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomFamilyState';
type AiChatThreadPendingDelete = {
threadId: string;
threadTitle: string;
} | null;
export const aiChatThreadPendingDeleteFamilyState = createAtomFamilyState<
AiChatThreadPendingDelete,
AiChatThreadActionsSurface
>({
key: 'aiChatThreadPendingDeleteFamilyState',
defaultValue: null,
});
@@ -1,4 +1,4 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilyState';
export const currentAiChatThreadTitleComponentFamilyState =
@@ -1,4 +1,4 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const processedToolExecutionPartIdsComponentState =
@@ -1,4 +1,4 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { createAtomComponentSelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentSelector';
@@ -1,4 +1,4 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { createAtomComponentSelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentSelector';
@@ -1,4 +1,4 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { createAtomComponentFamilySelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilySelector';
@@ -1,4 +1,4 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { createAtomComponentSelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentSelector';
@@ -1,4 +1,4 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { createAtomComponentSelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentSelector';
@@ -0,0 +1,50 @@
import { AGENT_CHAT_THREAD_FILTER_STATUS } from '@/ai/constants/AgentChatThreadFilterStatus';
import { AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER_DAYS } from '@/ai/constants/AgentChatThreadLastActivityFilterDays';
import { agentChatThreadFilterStatusState } from '@/ai/states/agentChatThreadFilterStatusState';
import { agentChatThreadLastActivityFilterState } from '@/ai/states/agentChatThreadLastActivityFilterState';
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { type FlatAgentChatThread } from '@/metadata-store/types/FlatAgentChatThread';
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
export const agentChatVisibleThreadsSelector = createAtomSelector<
FlatAgentChatThread[]
>({
key: 'agentChatVisibleThreadsSelector',
get: ({ get }) => {
const storeItem = get(metadataStoreState, 'agentChatThreads');
const allThreads = storeItem.current as FlatAgentChatThread[];
const filterStatus = get(agentChatThreadFilterStatusState);
const lastActivityFilter = get(agentChatThreadLastActivityFilterState);
const lastActivityDays =
AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER_DAYS[lastActivityFilter];
const cutoffMs =
lastActivityDays !== null
? Date.now() - lastActivityDays * MILLISECONDS_PER_DAY
: null;
return allThreads.filter((thread) => {
switch (filterStatus) {
case AGENT_CHAT_THREAD_FILTER_STATUS.ACTIVE:
if (thread.deletedAt) return false;
break;
case AGENT_CHAT_THREAD_FILTER_STATUS.ARCHIVED:
if (!thread.deletedAt) return false;
break;
case AGENT_CHAT_THREAD_FILTER_STATUS.ALL:
break;
}
if (cutoffMs !== null) {
const lastActivityMs = new Date(
thread.lastMessageAt ?? thread.updatedAt,
).getTime();
if (lastActivityMs < cutoffMs) return false;
}
return true;
});
},
});
@@ -0,0 +1,4 @@
import { type AGENT_CHAT_THREAD_FILTER_STATUS } from '@/ai/constants/AgentChatThreadFilterStatus';
export type AgentChatThreadFilterStatus =
(typeof AGENT_CHAT_THREAD_FILTER_STATUS)[keyof typeof AGENT_CHAT_THREAD_FILTER_STATUS];
@@ -0,0 +1,4 @@
import { type AGENT_CHAT_THREAD_GROUP_BY } from '@/ai/constants/AgentChatThreadGroupBy';
export type AgentChatThreadGroupBy =
(typeof AGENT_CHAT_THREAD_GROUP_BY)[keyof typeof AGENT_CHAT_THREAD_GROUP_BY];
@@ -0,0 +1,4 @@
import { type AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER } from '@/ai/constants/AgentChatThreadLastActivityFilter';
export type AgentChatThreadLastActivityFilter =
(typeof AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER)[keyof typeof AGENT_CHAT_THREAD_LAST_ACTIVITY_FILTER];
@@ -0,0 +1,4 @@
import { type AI_CHAT_THREAD_ACTIONS_SURFACE } from '@/ai/constants/AiChatThreadActionsSurface';
export type AiChatThreadActionsSurface =
(typeof AI_CHAT_THREAD_ACTIONS_SURFACE)[keyof typeof AI_CHAT_THREAD_ACTIONS_SURFACE];
@@ -0,0 +1,4 @@
import { type AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE } from '@/ai/constants/AiChatThreadFilterDropdownPage';
export type AiChatThreadFilterDropdownPage =
(typeof AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE)[keyof typeof AI_CHAT_THREAD_FILTER_DROPDOWN_PAGE];
@@ -2,13 +2,25 @@ import { type AgentChatThread } from '~/generated-metadata/graphql';
import { groupThreadsByDate } from '@/ai/utils/groupThreadsByDate';
describe('groupThreadsByDate', () => {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
const twoDaysAgo = new Date(today);
twoDaysAgo.setDate(today.getDate() - 2);
const today = new Date('2026-04-26T12:00:00');
const baseThread: Omit<AgentChatThread, 'updatedAt' | 'id'> = {
const getDateDaysAgo = (daysAgo: number) => {
const date = new Date(today);
date.setDate(today.getDate() - daysAgo);
return date;
};
const yesterday = getDateDaysAgo(1);
const twoDaysAgo = getDateDaysAgo(2);
const sevenDaysAgo = getDateDaysAgo(7);
const eightDaysAgo = getDateDaysAgo(8);
const fourteenDaysAgo = getDateDaysAgo(14);
const baseThread: Omit<
AgentChatThread,
'updatedAt' | 'id' | 'lastMessageAt'
> = {
title: 'Test Thread',
createdAt: twoDaysAgo.toISOString(),
totalInputTokens: 0,
@@ -19,26 +31,107 @@ describe('groupThreadsByDate', () => {
totalOutputCredits: 0,
};
const threads: AgentChatThread[] = [
{ ...baseThread, id: '1', updatedAt: today.toISOString() },
{ ...baseThread, id: '2', updatedAt: yesterday.toISOString() },
{ ...baseThread, id: '3', updatedAt: twoDaysAgo.toISOString() },
];
it('groups threads into today, yesterday, and older', () => {
const result = groupThreadsByDate(threads);
expect(result.today).toHaveLength(1);
expect(result.today[0].id).toBe('1');
expect(result.yesterday).toHaveLength(1);
expect(result.yesterday[0].id).toBe('2');
expect(result.older).toHaveLength(1);
expect(result.older[0].id).toBe('3');
const buildThread = (id: string, lastMessageAt: Date): AgentChatThread => ({
...baseThread,
id,
lastMessageAt: lastMessageAt.toISOString(),
updatedAt: lastMessageAt.toISOString(),
});
it('returns empty arrays if no threads', () => {
const result = groupThreadsByDate([]);
expect(result.today).toEqual([]);
expect(result.yesterday).toEqual([]);
expect(result.older).toEqual([]);
it('groups threads into Today, Yesterday, Previous 7 days, and month sections', () => {
const monthFormatter = new Intl.DateTimeFormat(undefined, {
month: 'long',
year: 'numeric',
});
const threads: AgentChatThread[] = [
buildThread('1', today),
buildThread('2', yesterday),
buildThread('3', twoDaysAgo),
buildThread('4', sevenDaysAgo),
buildThread('5', eightDaysAgo),
buildThread('6', fourteenDaysAgo),
];
const result = groupThreadsByDate(threads, today);
expect(result).toHaveLength(4);
expect(result[0]).toMatchObject({
id: 'today',
title: 'Today',
threads: [{ id: '1' }],
});
expect(result[1]).toMatchObject({
id: 'yesterday',
title: 'Yesterday',
threads: [{ id: '2' }],
});
expect(result[2]).toMatchObject({
id: 'previous-7-days',
title: 'Previous 7 days',
threads: [{ id: '3' }, { id: '4' }],
});
expect(result[3]).toMatchObject({
id: 'month:2026-4',
title: monthFormatter.format(eightDaysAgo),
threads: [{ id: '5' }, { id: '6' }],
});
});
it('returns no groups if no threads', () => {
expect(groupThreadsByDate([], today)).toEqual([]);
});
it('falls back to updatedAt when lastMessageAt is null', () => {
const thread: AgentChatThread = {
...baseThread,
id: '1',
lastMessageAt: null,
updatedAt: yesterday.toISOString(),
};
const [group] = groupThreadsByDate([thread], today);
expect(group.id).toBe('yesterday');
});
describe('timezone handling', () => {
it('keeps a thread last touched a few hours ago in Today across DST/midnight boundaries', () => {
const todayLate = new Date('2026-04-26T23:30:00');
const todayEarly = new Date('2026-04-26T00:15:00');
const result = groupThreadsByDate(
[buildThread('late', todayLate), buildThread('early', todayEarly)],
today,
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
id: 'today',
threads: [{ id: 'late' }, { id: 'early' }],
});
});
it('places a thread last touched late yesterday in the Yesterday bucket', () => {
const yesterdayJustBeforeMidnight = new Date('2026-04-25T23:59:00');
const [group] = groupThreadsByDate(
[buildThread('y', yesterdayJustBeforeMidnight)],
today,
);
expect(group.id).toBe('yesterday');
});
it('does not slip a thread from yesterday into Today when local times differ by hours', () => {
const yesterdayMorning = new Date('2026-04-25T08:00:00');
const [group] = groupThreadsByDate(
[buildThread('y', yesterdayMorning)],
today,
);
expect(group.id).toBe('yesterday');
});
});
});
@@ -1 +0,0 @@
export type DateGroupKey = 'today' | 'yesterday' | 'older';
@@ -1,7 +0,0 @@
import { type DateGroupKey } from '@/ai/utils/dateGroupKey';
export const DATE_GROUP_KEYS: readonly DateGroupKey[] = [
'today',
'yesterday',
'older',
];
@@ -0,0 +1,5 @@
import { type AiChatThreadActionsSurface } from '@/ai/types/AiChatThreadActionsSurface';
export const getAiChatThreadDeleteModalId = (
surface: AiChatThreadActionsSurface,
) => `delete-chat-thread-modal-${surface}`;
@@ -0,0 +1,5 @@
import { type AiChatThreadActionsSurface } from '@/ai/types/AiChatThreadActionsSurface';
export const getAiChatThreadFilterDropdownId = (
surface: AiChatThreadActionsSurface,
) => `ai-chat-thread-filter-${surface}`;
@@ -0,0 +1,6 @@
import { type AiChatThreadActionsSurface } from '@/ai/types/AiChatThreadActionsSurface';
export const getAiChatThreadItemMenuDropdownId = (
threadId: string,
surface: AiChatThreadActionsSurface,
) => `ai-chat-thread-item-menu-${surface}-${threadId}`;
@@ -1,14 +0,0 @@
import { t } from '@lingui/core/macro';
import { type DateGroupKey } from '@/ai/utils/dateGroupKey';
export const getDateGroupTitle = (key: DateGroupKey): string => {
switch (key) {
case 'today':
return t`Today`;
case 'yesterday':
return t`Yesterday`;
case 'older':
return t`Older`;
}
};
@@ -1,29 +1,83 @@
import { t } from '@lingui/core/macro';
import { differenceInCalendarDays } from 'date-fns';
import { type AgentChatThread } from '~/generated-metadata/graphql';
import { type DateGroupKey } from '@/ai/utils/dateGroupKey';
export type AgentChatThreadDateGroup = {
id: string;
title: string;
threads: AgentChatThread[];
};
const getLocalDayDifference = (date: Date, today: Date) =>
differenceInCalendarDays(today, date);
const getMonthGroupId = (date: Date) =>
`month:${date.getFullYear()}-${date.getMonth() + 1}`;
const formatMonthGroupTitle = (date: Date) =>
new Intl.DateTimeFormat(undefined, {
month: 'long',
year: 'numeric',
}).format(date);
const getThreadDateGroup = (
threadActivityAt: Date,
today: Date,
): Omit<AgentChatThreadDateGroup, 'threads'> => {
const localDayDifference = getLocalDayDifference(threadActivityAt, today);
if (localDayDifference === 0) {
return {
id: 'today',
title: t`Today`,
};
}
if (localDayDifference === 1) {
return {
id: 'yesterday',
title: t`Yesterday`,
};
}
if (localDayDifference >= 2 && localDayDifference <= 7) {
return {
id: 'previous-7-days',
title: t`Previous 7 days`,
};
}
return {
id: getMonthGroupId(threadActivityAt),
title: formatMonthGroupTitle(threadActivityAt),
};
};
export const groupThreadsByDate = (
threads: AgentChatThread[],
): Record<DateGroupKey, AgentChatThread[]> => {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
today = new Date(),
): AgentChatThreadDateGroup[] => {
const groupedThreadsByDate = new Map<string, AgentChatThreadDateGroup>();
return threads.reduce<Record<DateGroupKey, AgentChatThread[]>>(
(acc, thread) => {
const threadDate = new Date(thread.updatedAt);
const threadDateString = threadDate.toDateString();
for (const thread of threads) {
const threadDateGroup = getThreadDateGroup(
new Date(thread.lastMessageAt ?? thread.updatedAt),
today,
);
const existingThreadDateGroup = groupedThreadsByDate.get(
threadDateGroup.id,
);
if (threadDateString === today.toDateString()) {
acc.today.push(thread);
} else if (threadDateString === yesterday.toDateString()) {
acc.yesterday.push(thread);
} else {
acc.older.push(thread);
}
if (existingThreadDateGroup !== undefined) {
existingThreadDateGroup.threads.push(thread);
} else {
groupedThreadsByDate.set(threadDateGroup.id, {
...threadDateGroup,
threads: [thread],
});
}
}
return acc;
},
{ today: [], yesterday: [], older: [] },
);
return [...groupedThreadsByDate.values()];
};
@@ -0,0 +1,14 @@
type ThreadWithLastActivity = {
lastMessageAt?: string | Date | null;
updatedAt: string | Date;
};
const getLastActivityMs = (thread: ThreadWithLastActivity): number =>
new Date(thread.lastMessageAt ?? thread.updatedAt).getTime();
export const sortChatThreadsByLastActivityDesc = <
T extends ThreadWithLastActivity,
>(
threads: T[],
): T[] =>
threads.toSorted((a, b) => getLastActivityMs(b) - getLastActivityMs(a));
@@ -92,15 +92,15 @@ const StyledItem = styled.button<StyledItemProps>`
border-radius: ${themeCssVariables.border.radius.sm};
box-sizing: border-box;
color: ${({ active, isSoon, variant }) => {
if (variant === 'tertiary') {
return themeCssVariables.font.color.tertiary;
}
if (active === true) {
return themeCssVariables.font.color.primary;
}
if (isSoon) {
return themeCssVariables.font.color.light;
}
if (variant === 'tertiary') {
return themeCssVariables.font.color.tertiary;
}
return themeCssVariables.font.color.secondary;
}};
cursor: ${({ isSoon, isDragging }) =>
@@ -129,7 +129,10 @@ const StyledItem = styled.button<StyledItemProps>`
&:hover {
background: ${themeCssVariables.background.transparent.light};
color: ${themeCssVariables.font.color.primary};
color: ${({ variant }) =>
variant === 'tertiary'
? themeCssVariables.font.color.tertiary
: themeCssVariables.font.color.primary};
}
&:hover .keyboard-shortcuts {
@@ -0,0 +1,27 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.2.0', 1777682000000)
export class AddDeletedAtToAgentChatThreadFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."agentChatThread" ADD "deletedAt" TIMESTAMP WITH TIME ZONE`,
);
await queryRunner.query(
`CREATE INDEX "IDX_AGENT_CHAT_THREAD_ID_DELETED_AT" ON "core"."agentChatThread" ("id", "deletedAt")`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "core"."IDX_AGENT_CHAT_THREAD_ID_DELETED_AT"`,
);
await queryRunner.query(
`ALTER TABLE "core"."agentChatThread" DROP COLUMN "deletedAt"`,
);
}
}
@@ -20,6 +20,7 @@ import { AddProviderExecutedToAgentMessagePartFastInstanceCommand } from 'src/da
import { BackfillPageLayoutWidgetPositionSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-1/2-1-instance-command-slow-1795000002000-backfill-page-layout-widget-position';
import { AddCacheTokensToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777455269302-add-cache-tokens-to-agent-chat-thread';
import { AddLogoToApplicationFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777539664664-add-logo-to-application';
import { AddDeletedAtToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777682000000-add-deleted-at-to-agent-chat-thread';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
@@ -42,4 +43,5 @@ export const INSTANCE_COMMANDS = [
BackfillPageLayoutWidgetPositionSlowInstanceCommand,
AddCacheTokensToAgentChatThreadFastInstanceCommand,
AddLogoToApplicationFastInstanceCommand,
AddDeletedAtToAgentChatThreadFastInstanceCommand,
];

Some files were not shown because too many files have changed in this diff Show More