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
+1
View File
@@ -118,6 +118,7 @@
"outputs": ["{projectRoot}/coverage"], "outputs": ["{projectRoot}/coverage"],
"options": { "options": {
"jestConfig": "{projectRoot}/jest.config.mjs", "jestConfig": "{projectRoot}/jest.config.mjs",
"silent": true,
"coverage": true, "coverage": true,
"coverageReporters": ["text-summary"], "coverageReporters": ["text-summary"],
"cacheDirectory": "../../.cache/jest/{projectRoot}" "cacheDirectory": "../../.cache/jest/{projectRoot}"
@@ -85,12 +85,15 @@ export const AIChatAssistantMessageRenderer = ({
}) => { }) => {
// Filter out data-code-execution parts when tool-code_interpreter exists // Filter out data-code-execution parts when tool-code_interpreter exists
// (the tool part contains the final result, data-code-execution is for streaming updates) // (the tool part contains the final result, data-code-execution is for streaming updates)
// Also filter out data-thread-title (consumed by useAgentChat, not rendered)
const hasCodeInterpreterTool = messageParts.some( const hasCodeInterpreterTool = messageParts.some(
(part) => part.type === 'tool-code_interpreter', (part) => part.type === 'tool-code_interpreter',
); );
const filteredParts = hasCodeInterpreterTool const filteredParts = messageParts.filter(
? messageParts.filter((part) => part.type !== 'data-code-execution') (part) =>
: messageParts; part.type !== 'data-thread-title' &&
(!hasCodeInterpreterTool || part.type !== 'data-code-execution'),
);
const renderItems = groupContiguousThinkingStepParts(filteredParts); const renderItems = groupContiguousThinkingStepParts(filteredParts);
if (!renderItems.length && !hasError) { if (!renderItems.length && !hasError) {
@@ -1,5 +1,6 @@
import { agentChatUsageState } from '@/ai/states/agentChatUsageState'; import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState'; import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState';
import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu'; import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
@@ -78,11 +79,15 @@ export const AIChatThreadGroup = ({
const { t } = useLingui(); const { t } = useLingui();
const theme = useTheme(); const theme = useTheme();
const [, setCurrentAIChatThread] = useRecoilState(currentAIChatThreadState); const [, setCurrentAIChatThread] = useRecoilState(currentAIChatThreadState);
const setCurrentAIChatThreadTitle = useSetRecoilState(
currentAIChatThreadTitleState,
);
const setAgentChatUsage = useSetRecoilState(agentChatUsageState); const setAgentChatUsage = useSetRecoilState(agentChatUsageState);
const { openAskAIPage } = useOpenAskAIPageInCommandMenu(); const { openAskAIPage } = useOpenAskAIPageInCommandMenu();
const handleThreadClick = (thread: AgentChatThread) => { const handleThreadClick = (thread: AgentChatThread) => {
setCurrentAIChatThread(thread.id); setCurrentAIChatThread(thread.id);
setCurrentAIChatThreadTitle(thread.title ?? null);
const hasUsageData = const hasUsageData =
(thread.conversationSize ?? 0) > 0 && (thread.conversationSize ?? 0) > 0 &&
@@ -103,7 +108,6 @@ export const AIChatThreadGroup = ({
); );
openAskAIPage({ openAskAIPage({
pageTitle: thread.title,
resetNavigationStack: false, resetNavigationStack: false,
}); });
}; };
@@ -1,12 +1,12 @@
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { useGetBrowsingContext } from '@/ai/hooks/useBrowsingContext'; import { useGetBrowsingContext } from '@/ai/hooks/useBrowsingContext';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { agentChatSelectedFilesState } from '@/ai/states/agentChatSelectedFilesState'; import { agentChatSelectedFilesState } from '@/ai/states/agentChatSelectedFilesState';
import { agentChatUploadedFilesState } from '@/ai/states/agentChatUploadedFilesState'; import { agentChatUploadedFilesState } from '@/ai/states/agentChatUploadedFilesState';
import { agentChatUsageState } from '@/ai/states/agentChatUsageState'; import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState'; import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url'; import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
import { getTokenPair } from '@/apollo/utils/getTokenPair'; import { getTokenPair } from '@/apollo/utils/getTokenPair';
import { renewToken } from '@/auth/services/AuthService'; import { renewToken } from '@/auth/services/AuthService';
@@ -23,6 +23,9 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
const setAgentChatUsage = useSetRecoilState(agentChatUsageState); const setAgentChatUsage = useSetRecoilState(agentChatUsageState);
const { getBrowsingContext } = useGetBrowsingContext(); const { getBrowsingContext } = useGetBrowsingContext();
const setCurrentAIChatThreadTitle = useSetRecoilState(
currentAIChatThreadTitleState,
);
const agentChatSelectedFiles = useRecoilValue(agentChatSelectedFilesState); const agentChatSelectedFiles = useRecoilValue(agentChatSelectedFilesState);
@@ -150,6 +153,14 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
outputCredits: (prev?.outputCredits ?? 0) + usage.outputCredits, outputCredits: (prev?.outputCredits ?? 0) + usage.outputCredits,
})); }));
} }
const titlePart = message.parts.find(
(part) => part.type === 'data-thread-title',
);
if (isDefined(titlePart) && titlePart.type === 'data-thread-title') {
setCurrentAIChatThreadTitle(titlePart.data.title);
}
}, },
}); });
@@ -4,6 +4,7 @@ import {
type AgentChatUsageState, type AgentChatUsageState,
} from '@/ai/states/agentChatUsageState'; } from '@/ai/states/agentChatUsageState';
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState'; import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState';
import { isCreatingChatThreadState } from '@/ai/states/isCreatingChatThreadState'; import { isCreatingChatThreadState } from '@/ai/states/isCreatingChatThreadState';
import { mapDBMessagesToUIMessages } from '@/ai/utils/mapDBMessagesToUIMessages'; import { mapDBMessagesToUIMessages } from '@/ai/utils/mapDBMessagesToUIMessages';
import { import {
@@ -46,6 +47,9 @@ export const useAgentChatData = () => {
currentAIChatThreadState, currentAIChatThreadState,
); );
const setAgentChatUsage = useSetRecoilState(agentChatUsageState); const setAgentChatUsage = useSetRecoilState(agentChatUsageState);
const setCurrentAIChatThreadTitle = useSetRecoilState(
currentAIChatThreadTitleState,
);
const [isCreatingChatThread, setIsCreatingChatThread] = useRecoilState( const [isCreatingChatThread, setIsCreatingChatThread] = useRecoilState(
isCreatingChatThreadState, isCreatingChatThreadState,
); );
@@ -56,6 +60,7 @@ export const useAgentChatData = () => {
onCompleted: (data) => { onCompleted: (data) => {
setIsCreatingChatThread(false); setIsCreatingChatThread(false);
setCurrentAIChatThread(data.createChatThread.id); setCurrentAIChatThread(data.createChatThread.id);
setCurrentAIChatThreadTitle(null);
setAgentChatUsage(null); setAgentChatUsage(null);
}, },
onError: () => { onError: () => {
@@ -70,6 +75,7 @@ export const useAgentChatData = () => {
const firstThread = data.chatThreads[0]; const firstThread = data.chatThreads[0];
setCurrentAIChatThread(firstThread.id); setCurrentAIChatThread(firstThread.id);
setCurrentAIChatThreadTitle(firstThread.title ?? null);
setUsageFromThread(firstThread, setAgentChatUsage); setUsageFromThread(firstThread, setAgentChatUsage);
} else if (!isCreatingChatThread) { } else if (!isCreatingChatThread) {
setIsCreatingChatThread(true); setIsCreatingChatThread(true);
@@ -1,5 +1,6 @@
import { agentChatUsageState } from '@/ai/states/agentChatUsageState'; import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState'; import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState';
import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu'; import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu';
import { useRecoilState, useSetRecoilState } from 'recoil'; import { useRecoilState, useSetRecoilState } from 'recoil';
import { useCreateChatThreadMutation } from '~/generated-metadata/graphql'; import { useCreateChatThreadMutation } from '~/generated-metadata/graphql';
@@ -7,11 +8,15 @@ import { useCreateChatThreadMutation } from '~/generated-metadata/graphql';
export const useCreateNewAIChatThread = () => { export const useCreateNewAIChatThread = () => {
const [, setCurrentAIChatThread] = useRecoilState(currentAIChatThreadState); const [, setCurrentAIChatThread] = useRecoilState(currentAIChatThreadState);
const setAgentChatUsage = useSetRecoilState(agentChatUsageState); const setAgentChatUsage = useSetRecoilState(agentChatUsageState);
const setCurrentAIChatThreadTitle = useSetRecoilState(
currentAIChatThreadTitleState,
);
const { openAskAIPage } = useOpenAskAIPageInCommandMenu(); const { openAskAIPage } = useOpenAskAIPageInCommandMenu();
const [createChatThread] = useCreateChatThreadMutation({ const [createChatThread] = useCreateChatThreadMutation({
onCompleted: (data) => { onCompleted: (data) => {
setCurrentAIChatThread(data.createChatThread.id); setCurrentAIChatThread(data.createChatThread.id);
setCurrentAIChatThreadTitle(null);
setAgentChatUsage(null); setAgentChatUsage(null);
openAskAIPage({ resetNavigationStack: false }); openAskAIPage({ resetNavigationStack: false });
}, },
@@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const currentAIChatThreadTitleState = atom<string | null>({
key: 'ai/currentAIChatThreadTitleState',
default: null,
});
@@ -0,0 +1,25 @@
import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { OverflowingTextWithTooltip } from 'twenty-ui/display';
const StyledPageTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
`;
export const CommandMenuAskAIInfo = () => {
const currentAIChatThreadTitle = useRecoilValue(
currentAIChatThreadTitleState,
);
return (
<StyledPageTitle>
<OverflowingTextWithTooltip
text={currentAIChatThreadTitle ?? t`Ask AI`}
/>
</StyledPageTitle>
);
};
@@ -3,6 +3,7 @@ import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { OverflowingTextWithTooltip } from 'twenty-ui/display'; import { OverflowingTextWithTooltip } from 'twenty-ui/display';
import { CommandMenuAskAIInfo } from '@/command-menu/components/CommandMenuAskAIInfo';
import { CommandMenuFolderInfo } from '@/command-menu/components/CommandMenuFolderInfo'; import { CommandMenuFolderInfo } from '@/command-menu/components/CommandMenuFolderInfo';
import { CommandMenuLinkInfo } from '@/command-menu/components/CommandMenuLinkInfo'; import { CommandMenuLinkInfo } from '@/command-menu/components/CommandMenuLinkInfo';
import { CommandMenuMultipleRecordsInfo } from '@/command-menu/components/CommandMenuMultipleRecordsInfo'; import { CommandMenuMultipleRecordsInfo } from '@/command-menu/components/CommandMenuMultipleRecordsInfo';
@@ -114,6 +115,12 @@ export const CommandMenuPageInfo = ({ pageChip }: CommandMenuPageInfoProps) => {
); );
} }
const isAskAIPage = pageChip.page?.page === CommandMenuPages.AskAI;
if (isAskAIPage) {
return <CommandMenuAskAIInfo />;
}
return ( return (
<StyledPageTitle> <StyledPageTitle>
<OverflowingTextWithTooltip text={pageChip.text ?? ''} /> <OverflowingTextWithTooltip text={pageChip.text ?? ''} />
@@ -0,0 +1,81 @@
import { renderHook, act } from '@testing-library/react';
import { type MutableSnapshot, RecoilRoot } from 'recoil';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu';
import { IconSparkles } from 'twenty-ui/display';
const navigateCommandMenuMock = jest.fn();
jest.mock('@/command-menu/hooks/useCommandMenu', () => ({
useCommandMenu: () => ({
navigateCommandMenu: navigateCommandMenuMock,
openCommandMenu: jest.fn(),
closeCommandMenu: jest.fn(),
toggleCommandMenu: jest.fn(),
}),
}));
const renderWithRecoil = (
initializeState?: (snapshot: MutableSnapshot) => void,
) =>
renderHook(() => useOpenAskAIPageInCommandMenu(), {
wrapper: ({ children }) => (
<RecoilRoot initializeState={initializeState}>{children}</RecoilRoot>
),
});
describe('useOpenAskAIPageInCommandMenu', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should navigate to AskAI page with correct defaults', () => {
const { result } = renderWithRecoil();
act(() => {
result.current.openAskAIPage();
});
expect(navigateCommandMenuMock).toHaveBeenCalledWith(
expect.objectContaining({
page: CommandMenuPages.AskAI,
pageTitle: 'Ask AI',
pageIcon: IconSparkles,
}),
);
});
it('should use resetNavigationStack from argument when provided', () => {
const { result } = renderWithRecoil((snapshot) => {
snapshot.set(isCommandMenuOpenedState, true);
});
act(() => {
result.current.openAskAIPage({ resetNavigationStack: false });
});
expect(navigateCommandMenuMock).toHaveBeenCalledWith(
expect.objectContaining({
resetNavigationStack: false,
}),
);
});
it('should default resetNavigationStack to isCommandMenuOpened', () => {
const { result } = renderWithRecoil((snapshot) => {
snapshot.set(isCommandMenuOpenedState, true);
});
act(() => {
result.current.openAskAIPage();
});
expect(navigateCommandMenuMock).toHaveBeenCalledWith(
expect.objectContaining({
resetNavigationStack: true,
}),
);
});
});
@@ -2,34 +2,39 @@ import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState'; import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil'; import { useRecoilCallback } from 'recoil';
import { IconSparkles } from 'twenty-ui/display'; import { IconSparkles } from 'twenty-ui/display';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
export const useOpenAskAIPageInCommandMenu = () => { export const useOpenAskAIPageInCommandMenu = () => {
const { navigateCommandMenu } = useCommandMenu(); const { navigateCommandMenu } = useCommandMenu();
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
const openAskAIPage = ({ const openAskAIPage = useRecoilCallback(
pageTitle, ({ snapshot }) =>
resetNavigationStack, ({
}: { resetNavigationStack,
pageTitle?: string | null; }: {
resetNavigationStack?: boolean; resetNavigationStack?: boolean;
} = {}) => { } = {}) => {
const shouldReset = const isCommandMenuOpened = snapshot
resetNavigationStack !== undefined .getLoadable(isCommandMenuOpenedState)
? resetNavigationStack .getValue();
: isCommandMenuOpened;
navigateCommandMenu({ const shouldReset =
page: CommandMenuPages.AskAI, resetNavigationStack !== undefined
pageTitle: pageTitle ?? t`Ask AI`, ? resetNavigationStack
pageIcon: IconSparkles, : isCommandMenuOpened;
pageId: v4(),
resetNavigationStack: shouldReset, navigateCommandMenu({
}); page: CommandMenuPages.AskAI,
}; pageTitle: t`Ask AI`,
pageIcon: IconSparkles,
pageId: v4(),
resetNavigationStack: shouldReset,
});
},
[navigateCommandMenu],
);
return { return {
openAskAIPage, openAskAIPage,
@@ -86,6 +86,6 @@ describe('processSingleDrag', () => {
recordsWithPosition: [], recordsWithPosition: [],
isDroppedAfterList: false, isDroppedAfterList: false,
}), }),
).toThrowError('Cannot find item to move for id : record-1'); ).toThrow('Cannot find item to move for id : record-1');
}); });
}); });
@@ -80,15 +80,13 @@ describe('useCreateSSOIdentityProvider', () => {
const OTHERParams = { const OTHERParams = {
type: 'OTHER' as const, type: 'OTHER' as const,
}; };
renderHook( const { result } = renderHook(() => useCreateSSOIdentityProvider(), {
async () => { wrapper: Wrapper,
const { createSSOIdentityProvider } = useCreateSSOIdentityProvider(); });
await expect(
// @ts-expect-error - It's expected to throw an error await expect(
createSSOIdentityProvider(OTHERParams), // @ts-expect-error - It's expected to throw an error
).rejects.toThrowError(); result.current.createSSOIdentityProvider(OTHERParams),
}, ).rejects.toThrow('Invalid IdpType');
{ wrapper: Wrapper },
);
}); });
}); });
@@ -40,7 +40,7 @@ describe('getCronTriggerDefaultSettings', () => {
it('throws an error for an invalid interval', () => { it('throws an error for an invalid interval', () => {
// @ts-expect-error Testing invalid input // @ts-expect-error Testing invalid input
expect(() => getCronTriggerDefaultSettings('INVALID')).toThrowError( expect(() => getCronTriggerDefaultSettings('INVALID')).toThrow(
'Invalid cron trigger interval', 'Invalid cron trigger interval',
); );
}); });
@@ -32,7 +32,7 @@ describe('getWebhookTriggerDefaultSettings', () => {
it('throws an error for an invalid http method', () => { it('throws an error for an invalid http method', () => {
// @ts-expect-error Testing invalid input // @ts-expect-error Testing invalid input
expect(() => getWebhookTriggerDefaultSettings('INVALID')).toThrowError( expect(() => getWebhookTriggerDefaultSettings('INVALID')).toThrow(
'Invalid webhook http method', 'Invalid webhook http method',
); );
}); });
@@ -66,6 +66,9 @@ export const mapUIMessagePartsToDBParts = (
// Code execution parts are streamed during execution but don't need // Code execution parts are streamed during execution but don't need
// to be persisted - the final result is captured in the tool part // to be persisted - the final result is captured in the tool part
return null; return null;
case 'data-thread-title':
// Thread title is a transient notification for the client
return null;
default: default:
{ {
if (isToolPart(part)) { if (isToolPart(part)) {
@@ -82,6 +82,14 @@ export class AgentChatStreamingService {
// surfaces when awaited in onFinish. // surfaces when awaited in onFinish.
userMessagePromise.catch(() => {}); 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 { try {
const uiStream = createUIMessageStream<ExtendedUIMessage>({ const uiStream = createUIMessageStream<ExtendedUIMessage>({
execute: async ({ writer }) => { execute: async ({ writer }) => {
@@ -214,6 +222,16 @@ export class AgentChatStreamingService {
contextWindowTokens: modelConfig.contextWindowTokens, contextWindowTokens: modelConfig.contextWindowTokens,
conversationSize: lastStepConversationSize, 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, sendReasoning: true,
}), }),
@@ -113,16 +113,6 @@ export class AgentChatService {
await this.messagePartRepository.save(dbParts); 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; return savedMessage;
} }
@@ -148,22 +138,24 @@ export class AgentChatService {
}); });
} }
private async generateTitleIfNeeded( async generateTitleIfNeeded(
threadId: string, threadId: string,
messageContent: string, messageContent: string,
) { ): Promise<string | null> {
const thread = await this.threadRepository.findOne({ const thread = await this.threadRepository.findOne({
where: { id: threadId }, where: { id: threadId },
select: ['id', 'title'], select: ['id', 'title'],
}); });
if (!thread || thread.title || !messageContent) { if (!thread || thread.title || !messageContent) {
return; return null;
} }
const title = const title =
await this.titleGenerationService.generateThreadTitle(messageContent); await this.titleGenerationService.generateThreadTitle(messageContent);
await this.threadRepository.update(threadId, { title }); await this.threadRepository.update(threadId, { title });
return title;
} }
} }
@@ -65,4 +65,5 @@ export type DataMessagePart = {
}; };
}; };
'code-execution': CodeExecutionData; 'code-execution': CodeExecutionData;
'thread-title': { title: string };
}; };