[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:
@@ -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
-1
@@ -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';
|
||||
|
||||
+10
-12
@@ -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';
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+54
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
+60
@@ -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>
|
||||
);
|
||||
};
|
||||
+64
@@ -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>
|
||||
);
|
||||
};
|
||||
+119
@@ -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>
|
||||
);
|
||||
};
|
||||
+60
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
-76
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+79
@@ -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>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+1
-1
@@ -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;
|
||||
+12
@@ -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,
|
||||
};
|
||||
+15
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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';
|
||||
+50
@@ -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));
|
||||
+7
-4
@@ -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 {
|
||||
|
||||
+27
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
@@ -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
Reference in New Issue
Block a user