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:
+3
@@ -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)) {
|
||||
|
||||
+18
@@ -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,
|
||||
}),
|
||||
|
||||
+5
-13
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user