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

## PR Description

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

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


https://github.com/user-attachments/assets/1b179b7b-1a2a-4a7a-aa0a-c88f6f051a87
This commit is contained in:
nitin
2026-04-30 21:12:10 +05:30
committed by GitHub
parent 4b76457217
commit e1828b6f41
111 changed files with 2915 additions and 1139 deletions
@@ -1,14 +1,6 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SortDirection } from '@ptc-org/nestjs-query-core';
import {
NestjsQueryGraphQLModule,
PagingStrategies,
} from '@ptc-org/nestjs-query-graphql';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { PermissionFlagType } from 'twenty-shared/constants';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
@@ -19,8 +11,6 @@ import { ToolProviderModule } from 'src/engine/core-modules/tool-provider/tool-p
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { AiAgentExecutionModule } from 'src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module';
import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module';
import { AiGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/interceptors/ai-graphql-api-exception.interceptor';
@@ -32,7 +22,6 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
import { DashboardToolsModule } from 'src/modules/dashboard/tools/dashboard-tools.module';
import { WorkflowToolsModule } from 'src/modules/workflow/workflow-tools/workflow-tools.module';
import { AgentChatThreadDTO } from './dtos/agent-chat-thread.dto';
import { AgentChatThreadEntity } from './entities/agent-chat-thread.entity';
import { StreamAgentChatJob } from './jobs/stream-agent-chat.job';
import { AgentChatResolver } from './resolvers/agent-chat.resolver';
@@ -54,33 +43,6 @@ import { SystemPromptBuilderService } from './services/system-prompt-builder.ser
UserWorkspaceEntity,
WorkspaceEntity,
]),
NestjsQueryGraphQLModule.forFeature({
imports: [
NestjsQueryTypeOrmModule.forFeature([AgentChatThreadEntity]),
PermissionsModule,
],
resolvers: [
{
EntityClass: AgentChatThreadEntity,
DTOClass: AgentChatThreadDTO,
pagingStrategy: PagingStrategies.CURSOR,
read: {
defaultSort: [
{ field: 'updatedAt', direction: SortDirection.DESC },
],
one: { disabled: true },
many: { name: 'chatThreads' },
},
create: { disabled: true },
update: { disabled: true },
delete: { disabled: true },
guards: [
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.AI),
],
},
],
}),
AiAgentExecutionModule,
BillingModule,
ThrottlerModule,
@@ -1,32 +1,8 @@
import { UnauthorizedException } from '@nestjs/common';
import { Field, Float, HideField, Int, ObjectType } from '@nestjs/graphql';
import {
Authorize,
FilterableField,
IDField,
} from '@ptc-org/nestjs-query-graphql';
import { isDefined } from 'twenty-shared/utils';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
import { Field, Float, HideField, ID, Int, ObjectType } from '@nestjs/graphql';
@ObjectType('AgentChatThread')
@Authorize({
authorize: (context: { req?: AuthenticatedRequest }) => {
const userWorkspaceId = context?.req?.userWorkspaceId;
if (!isDefined(userWorkspaceId)) {
throw new UnauthorizedException(
'userWorkspaceId is required to query chat threads',
);
}
return { userWorkspaceId: { eq: userWorkspaceId } };
},
})
export class AgentChatThreadDTO {
@IDField(() => UUIDScalarType)
@Field(() => ID)
id: string;
@Field({ nullable: true })
@@ -55,10 +31,15 @@ export class AgentChatThreadDTO {
@Field()
createdAt: Date;
@FilterableField()
@Field()
updatedAt: Date;
@Field(() => Date, { nullable: true })
deletedAt: Date | null;
@Field(() => Date, { nullable: true })
lastMessageAt: Date | null;
@HideField()
userWorkspaceId: string;
}
@@ -17,6 +17,7 @@ import type { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspac
import { EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
@Entity({ name: 'agentChatThread', schema: 'core' })
@Index('IDX_AGENT_CHAT_THREAD_ID_DELETED_AT', ['id', 'deletedAt'])
export class AgentChatThreadEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@@ -75,6 +76,9 @@ export class AgentChatThreadEntity {
@OneToMany(() => AgentMessageEntity, (message) => message.thread)
messages: EntityRelation<AgentMessageEntity[]>;
@Column({ type: 'timestamptz', nullable: true })
deletedAt: Date | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@@ -449,6 +449,15 @@ export class StreamAgentChatJob {
return;
}
const threadStatus = await this.threadRepository.findOne({
where: { id: threadId },
select: ['id', 'deletedAt'],
});
if (!threadStatus || threadStatus.deletedAt) {
return;
}
const userMessage = await userMessagePromise;
await this.agentChatService.addMessage({
@@ -59,6 +59,11 @@ export class AgentChatResolver {
private readonly threadRepository: Repository<AgentChatThreadEntity>,
) {}
@Query(() => [AgentChatThreadDTO])
async chatThreads(@AuthUserWorkspaceId() userWorkspaceId: string) {
return this.agentChatService.getThreadsForUser(userWorkspaceId);
}
@Query(() => AgentChatThreadDTO)
async chatThread(
@Args('id', { type: () => UUIDScalarType }) id: string,
@@ -140,6 +145,13 @@ export class AgentChatResolver {
);
}
if (isDefined(thread.deletedAt)) {
await this.agentChatService.unarchiveThread({
threadId,
userWorkspaceId,
});
}
if (isDefined(thread.activeStreamId)) {
const queuedMessage = await this.agentChatService.queueMessage({
threadId,
@@ -147,6 +159,7 @@ export class AgentChatResolver {
id: messageId,
fileIds: fileIds ?? undefined,
workspaceId: workspace.id,
userWorkspaceId,
});
await this.eventPublisherService.publish({
@@ -201,6 +214,75 @@ export class AgentChatResolver {
return true;
}
@Mutation(() => AgentChatThreadDTO)
async renameChatThread(
@Args('id', { type: () => UUIDScalarType }) id: string,
@Args('title') title: string,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<AgentChatThreadEntity> {
return this.agentChatService.updateThreadTitle({
threadId: id,
userWorkspaceId,
title,
});
}
@Mutation(() => AgentChatThreadDTO)
async archiveChatThread(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<AgentChatThreadEntity> {
await this.cancelActiveStreamIfAny(id, userWorkspaceId);
return this.agentChatService.archiveThread({
threadId: id,
userWorkspaceId,
});
}
@Mutation(() => AgentChatThreadDTO)
async unarchiveChatThread(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<AgentChatThreadEntity> {
return this.agentChatService.unarchiveThread({
threadId: id,
userWorkspaceId,
});
}
@Mutation(() => Boolean)
async deleteChatThread(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<boolean> {
await this.cancelActiveStreamIfAny(id, userWorkspaceId);
await this.agentChatService.hardDeleteThread({
threadId: id,
userWorkspaceId,
});
return true;
}
private async cancelActiveStreamIfAny(
threadId: string,
userWorkspaceId: string,
): Promise<void> {
const thread = await this.threadRepository.findOne({
where: { id: threadId, userWorkspaceId },
});
if (!isDefined(thread) || !isDefined(thread.activeStreamId)) {
return;
}
const redis = this.redisClientService.getClient();
await redis.publish(getCancelChannel(threadId), 'cancel');
}
@Mutation(() => Boolean)
async deleteQueuedChatMessage(
@Args('messageId', { type: () => UUIDScalarType }) messageId: string,
@@ -261,4 +343,16 @@ export class AgentChatResolver {
totalOutputCredits(@Parent() thread: AgentChatThreadEntity): number {
return toDisplayCredits(thread.totalOutputCredits);
}
@ResolveField('lastMessageAt', () => Date, { nullable: true })
async lastMessageAt(
@Parent()
thread: AgentChatThreadEntity & { lastMessageAt?: Date | null },
): Promise<Date | null> {
if (thread.lastMessageAt !== undefined) {
return thread.lastMessageAt;
}
return this.agentChatService.getLastMessageAtForThread(thread.id);
}
}
@@ -100,6 +100,11 @@ export class AgentChatStreamingService {
workspaceId: workspace.id,
});
await this.agentChatService.notifyThreadActivityUpdated(
threadId,
userWorkspaceId,
);
const previousMessages = await this.loadMessagesFromDB(
threadId,
userWorkspaceId,
@@ -139,6 +144,15 @@ export class AgentChatStreamingService {
workspaceId: string,
hasTitle: boolean,
): Promise<void> {
const threadStatus = await this.threadRepository.findOne({
where: { id: threadId },
select: ['id', 'deletedAt'],
});
if (!threadStatus || threadStatus.deletedAt) {
return;
}
const queuedMessages =
await this.agentChatService.getQueuedMessages(threadId);
@@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ExtendedUIMessage } from 'twenty-shared/ai';
import { In, Repository } from 'typeorm';
import { In, IsNull, Not, Repository } from 'typeorm';
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import type { UIDataTypes, UIMessagePart, UITools } from 'ai';
@@ -16,30 +16,39 @@ import {
} from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity';
import { AgentTurnEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-turn.entity';
import { mapUIMessagePartsToDBParts } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/mapUIMessagePartsToDBParts';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
import {
AiException,
AiExceptionCode,
} from 'src/engine/metadata-modules/ai/ai.exception';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
import { WorkspaceEventBroadcaster } from 'src/engine/subscriptions/workspace-event-broadcaster/workspace-event-broadcaster.service';
import { AgentTitleGenerationService } from './agent-title-generation.service';
const serializeThreadForBroadcast = (thread: AgentChatThreadEntity) => ({
const serializeThreadForBroadcast = (
thread: AgentChatThreadEntity,
lastMessageAt: Date | null,
) => ({
id: thread.id,
title: thread.title,
totalInputTokens: thread.totalInputTokens,
totalOutputTokens: thread.totalOutputTokens,
totalCacheReadTokens: thread.totalCacheReadTokens,
totalCacheCreationTokens: thread.totalCacheCreationTokens,
contextWindowTokens: thread.contextWindowTokens,
conversationSize: thread.conversationSize,
totalInputCredits: thread.totalInputCredits,
totalOutputCredits: thread.totalOutputCredits,
createdAt: thread.createdAt.toISOString(),
updatedAt: thread.updatedAt.toISOString(),
deletedAt: thread.deletedAt,
lastMessageAt,
createdAt: thread.createdAt,
updatedAt: thread.updatedAt,
});
@Injectable()
export class AgentChatService {
private readonly logger = new Logger(AgentChatService.name);
constructor(
@InjectRepository(AgentChatThreadEntity)
private readonly threadRepository: Repository<AgentChatThreadEntity>,
@@ -78,7 +87,7 @@ export class AgentChatService {
recordId: savedThread.id,
recipientUserWorkspaceIds: [userWorkspaceId],
properties: {
after: serializeThreadForBroadcast(savedThread),
after: serializeThreadForBroadcast(savedThread, null),
},
},
],
@@ -105,6 +114,53 @@ export class AgentChatService {
return thread;
}
async getThreadsForUser(
userWorkspaceId: string,
): Promise<(AgentChatThreadEntity & { lastMessageAt: Date | null })[]> {
const rankedThreads = await this.threadRepository
.createQueryBuilder('thread')
.select('thread.id', 'id')
.addSelect('MAX(message.createdAt)', 'last_message_at')
.leftJoin('thread.messages', 'message')
.where('thread.userWorkspaceId = :userWorkspaceId', { userWorkspaceId })
.groupBy('thread.id')
.orderBy('last_message_at', 'DESC', 'NULLS LAST')
.addOrderBy('thread.updatedAt', 'DESC')
.getRawMany<{ id: string; last_message_at: Date | null }>();
if (rankedThreads.length === 0) {
return [];
}
const rankedThreadIds = rankedThreads.map(
(rankedThread) => rankedThread.id,
);
const threads = await this.threadRepository.find({
where: { id: In(rankedThreadIds), userWorkspaceId },
});
const threadById = new Map(threads.map((thread) => [thread.id, thread]));
return rankedThreads.flatMap((rankedThread) => {
const thread = threadById.get(rankedThread.id);
return thread
? [{ ...thread, lastMessageAt: rankedThread.last_message_at ?? null }]
: [];
});
}
async getLastMessageAtForThread(threadId: string): Promise<Date | null> {
const result = await this.messageRepository
.createQueryBuilder('message')
.select('MAX(message.createdAt)', 'last_message_at')
.where('message.threadId = :threadId', { threadId })
.getRawOne<{ last_message_at: Date | null }>();
return result?.last_message_at ?? null;
}
async addMessage({
threadId,
uiMessage,
@@ -198,12 +254,14 @@ export class AgentChatService {
id,
fileIds,
workspaceId,
userWorkspaceId,
}: {
threadId: string;
text: string;
id?: string;
fileIds?: string[];
workspaceId: string;
userWorkspaceId: string;
}): Promise<AgentMessageEntity> {
const messageValues = {
...(id ? { id } : {}),
@@ -246,6 +304,8 @@ export class AgentChatService {
await this.messagePartRepository.insert(parts);
await this.notifyThreadActivityUpdated(threadId, userWorkspaceId);
return {
id: savedMessageId,
...messageValues,
@@ -311,6 +371,188 @@ export class AgentChatService {
return savedTurnId;
}
async updateThreadTitle({
threadId,
userWorkspaceId,
title,
}: {
threadId: string;
userWorkspaceId: string;
title: string;
}): Promise<AgentChatThreadEntity> {
const trimmed = title.trim();
if (trimmed.length === 0) {
throw new AiException(
'Chat thread title cannot be empty',
AiExceptionCode.INVALID_CHAT_THREAD_TITLE,
);
}
const result = await this.threadRepository.update(
{ id: threadId, userWorkspaceId },
{ title: trimmed },
);
if (result.affected === 0) {
throw new AiException(
'Thread not found',
AiExceptionCode.THREAD_NOT_FOUND,
);
}
const updated = await this.getThreadById(threadId, userWorkspaceId);
await this.broadcastThreadUpdated(updated, ['title'], userWorkspaceId);
return updated;
}
async archiveThread({
threadId,
userWorkspaceId,
}: {
threadId: string;
userWorkspaceId: string;
}): Promise<AgentChatThreadEntity> {
const thread = await this.getThreadById(threadId, userWorkspaceId);
if (thread.deletedAt) {
return thread;
}
const deletedAt = new Date();
const result = await this.threadRepository.update(
{ id: threadId, userWorkspaceId, deletedAt: IsNull() },
{ deletedAt, activeStreamId: null },
);
if ((result.affected ?? 0) === 0) {
return thread;
}
thread.deletedAt = deletedAt;
thread.activeStreamId = null;
await this.broadcastThreadUpdated(thread, ['deletedAt'], userWorkspaceId);
return thread;
}
async unarchiveThread({
threadId,
userWorkspaceId,
}: {
threadId: string;
userWorkspaceId: string;
}): Promise<AgentChatThreadEntity> {
const thread = await this.getThreadById(threadId, userWorkspaceId);
if (!thread.deletedAt) {
return thread;
}
const result = await this.threadRepository.update(
{ id: threadId, userWorkspaceId, deletedAt: Not(IsNull()) },
{ deletedAt: null },
);
if ((result.affected ?? 0) === 0) {
return thread;
}
thread.deletedAt = null;
await this.broadcastThreadUpdated(thread, ['deletedAt'], userWorkspaceId);
return thread;
}
async hardDeleteThread({
threadId,
userWorkspaceId,
}: {
threadId: string;
userWorkspaceId: string;
}): Promise<void> {
const thread = await this.threadRepository.findOne({
where: { id: threadId, userWorkspaceId },
});
if (!thread) {
throw new AiException(
'Thread not found',
AiExceptionCode.THREAD_NOT_FOUND,
);
}
const result = await this.threadRepository.delete({
id: threadId,
userWorkspaceId,
});
if ((result.affected ?? 0) === 0) {
this.logger.warn(
`hardDeleteThread: thread ${threadId} vanished between fetch and delete`,
);
return;
}
await this.workspaceEventBroadcaster.broadcast({
workspaceId: thread.workspaceId,
events: [
{
type: 'deleted',
entityName: 'agentChatThread',
recordId: threadId,
recipientUserWorkspaceIds: [userWorkspaceId],
properties: {
before: serializeThreadForBroadcast(thread, null),
},
},
],
});
}
async notifyThreadActivityUpdated(
threadId: string,
userWorkspaceId: string,
): Promise<void> {
const thread = await this.getThreadById(threadId, userWorkspaceId);
await this.broadcastThreadUpdated(
thread,
['lastMessageAt'],
userWorkspaceId,
);
}
private async broadcastThreadUpdated(
thread: AgentChatThreadEntity,
updatedFields: string[],
userWorkspaceId: string,
): Promise<void> {
const lastMessageAt = await this.getLastMessageAtForThread(thread.id);
await this.workspaceEventBroadcaster.broadcast({
workspaceId: thread.workspaceId,
events: [
{
type: 'updated',
entityName: 'agentChatThread',
recordId: thread.id,
recipientUserWorkspaceIds: [userWorkspaceId],
properties: {
updatedFields,
after: serializeThreadForBroadcast(thread, lastMessageAt),
},
},
],
});
}
async generateTitleIfNeeded({
threadId,
messageContent,
@@ -336,21 +578,11 @@ export class AgentChatService {
await this.threadRepository.update(threadId, { title });
await this.workspaceEventBroadcaster.broadcast({
workspaceId,
events: [
{
type: 'updated',
entityName: 'agentChatThread',
recordId: threadId,
recipientUserWorkspaceIds: [thread.userWorkspaceId],
properties: {
updatedFields: ['title'],
after: serializeThreadForBroadcast({ ...thread, title }),
},
},
],
});
await this.broadcastThreadUpdated(
{ ...thread, title },
['title'],
thread.userWorkspaceId,
);
return title;
}
@@ -11,6 +11,7 @@ export enum AiExceptionCode {
AGENT_EXECUTION_FAILED = 'AGENT_EXECUTION_FAILED',
INVALID_AGENT_INPUT = 'INVALID_AGENT_INPUT',
THREAD_NOT_FOUND = 'THREAD_NOT_FOUND',
INVALID_CHAT_THREAD_TITLE = 'INVALID_CHAT_THREAD_TITLE',
MESSAGE_NOT_FOUND = 'MESSAGE_NOT_FOUND',
API_KEY_NOT_CONFIGURED = 'API_KEY_NOT_CONFIGURED',
USER_WORKSPACE_ID_NOT_FOUND = 'USER_WORKSPACE_ID_NOT_FOUND',
@@ -32,6 +33,8 @@ const getAiExceptionUserFriendlyMessage = (code: AiExceptionCode) => {
return msg`Invalid agent input.`;
case AiExceptionCode.THREAD_NOT_FOUND:
return msg`Chat thread not found.`;
case AiExceptionCode.INVALID_CHAT_THREAD_TITLE:
return msg`Chat thread title cannot be empty.`;
case AiExceptionCode.MESSAGE_NOT_FOUND:
return msg`Chat message not found.`;
case AiExceptionCode.API_KEY_NOT_CONFIGURED:
@@ -21,6 +21,7 @@ export const aiGraphqlApiExceptionHandler = (error: Error) => {
case AiExceptionCode.ROLE_NOT_FOUND:
throw new NotFoundError(error);
case AiExceptionCode.INVALID_AGENT_INPUT:
case AiExceptionCode.INVALID_CHAT_THREAD_TITLE:
throw new UserInputError(error);
case AiExceptionCode.AGENT_ALREADY_EXISTS:
throw new ConflictError(error);