feat: show auto-generated conversation title for AI chat. (#17922)

## Summary
Replaces the static "Ask AI" header in the command menu with the
conversation’s auto-generated title once it’s set after the first
message.

## Changes
- **Backend:** Title is generated after the first user message (existing
behavior).
- **Frontend:** After the first stream completes, we fetch the thread
title and sync it to:
- `currentAIChatThreadTitleState` (persists across command menu
close/reopen)
- Command menu page info and navigation stack (so the title survives
back navigation)
- **Entry points:** Opening Ask AI from the left nav or command center
uses the same title resolution (explicit `pageTitle` → current thread
title → "Ask AI" fallback).
- **Race fix:** Title sync only runs when the thread that finished
streaming is still the active thread, so switching threads mid-stream
doesn’t overwrite the current thread’s title.

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Abdullah.
2026-02-17 17:46:35 +05:00
committed by GitHub
parent e80e9a6a25
commit 09e1684300
19 changed files with 219 additions and 53 deletions
@@ -66,6 +66,9 @@ export const mapUIMessagePartsToDBParts = (
// Code execution parts are streamed during execution but don't need
// to be persisted - the final result is captured in the tool part
return null;
case 'data-thread-title':
// Thread title is a transient notification for the client
return null;
default:
{
if (isToolPart(part)) {
@@ -82,6 +82,14 @@ export class AgentChatStreamingService {
// surfaces when awaited in onFinish.
userMessagePromise.catch(() => {});
// Title generation runs in parallel with AI streaming so it's
// typically ready by the time onFinish fires
const titlePromise = thread.title
? Promise.resolve(null)
: this.agentChatService
.generateTitleIfNeeded(thread.id, lastUserText)
.catch(() => null);
try {
const uiStream = createUIMessageStream<ExtendedUIMessage>({
execute: async ({ writer }) => {
@@ -214,6 +222,16 @@ export class AgentChatStreamingService {
contextWindowTokens: modelConfig.contextWindowTokens,
conversationSize: lastStepConversationSize,
});
const generatedTitle = await titlePromise;
if (generatedTitle) {
writer.write({
type: 'data-thread-title' as const,
id: `thread-title-${thread.id}`,
data: { title: generatedTitle },
});
}
},
sendReasoning: true,
}),
@@ -113,16 +113,6 @@ export class AgentChatService {
await this.messagePartRepository.save(dbParts);
}
if (uiMessage.role === AgentMessageRole.USER) {
const messageContent = uiMessage.parts.find(
(part) => part.type === 'text',
)?.text;
if (messageContent) {
this.generateTitleIfNeeded(threadId, messageContent);
}
}
return savedMessage;
}
@@ -148,22 +138,24 @@ export class AgentChatService {
});
}
private async generateTitleIfNeeded(
async generateTitleIfNeeded(
threadId: string,
messageContent: string,
) {
): Promise<string | null> {
const thread = await this.threadRepository.findOne({
where: { id: threadId },
select: ['id', 'title'],
});
if (!thread || thread.title || !messageContent) {
return;
return null;
}
const title =
await this.titleGenerationService.generateThreadTitle(messageContent);
await this.threadRepository.update(threadId, { title });
return title;
}
}