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:
@@ -118,6 +118,7 @@
|
||||
"outputs": ["{projectRoot}/coverage"],
|
||||
"options": {
|
||||
"jestConfig": "{projectRoot}/jest.config.mjs",
|
||||
"silent": true,
|
||||
"coverage": true,
|
||||
"coverageReporters": ["text-summary"],
|
||||
"cacheDirectory": "../../.cache/jest/{projectRoot}"
|
||||
|
||||
@@ -85,12 +85,15 @@ export const AIChatAssistantMessageRenderer = ({
|
||||
}) => {
|
||||
// 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)
|
||||
// Also filter out data-thread-title (consumed by useAgentChat, not rendered)
|
||||
const hasCodeInterpreterTool = messageParts.some(
|
||||
(part) => part.type === 'tool-code_interpreter',
|
||||
);
|
||||
const filteredParts = hasCodeInterpreterTool
|
||||
? messageParts.filter((part) => part.type !== 'data-code-execution')
|
||||
: messageParts;
|
||||
const filteredParts = messageParts.filter(
|
||||
(part) =>
|
||||
part.type !== 'data-thread-title' &&
|
||||
(!hasCodeInterpreterTool || part.type !== 'data-code-execution'),
|
||||
);
|
||||
const renderItems = groupContiguousThinkingStepParts(filteredParts);
|
||||
|
||||
if (!renderItems.length && !hasError) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState';
|
||||
import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
@@ -78,11 +79,15 @@ export const AIChatThreadGroup = ({
|
||||
const { t } = useLingui();
|
||||
const theme = useTheme();
|
||||
const [, setCurrentAIChatThread] = useRecoilState(currentAIChatThreadState);
|
||||
const setCurrentAIChatThreadTitle = useSetRecoilState(
|
||||
currentAIChatThreadTitleState,
|
||||
);
|
||||
const setAgentChatUsage = useSetRecoilState(agentChatUsageState);
|
||||
const { openAskAIPage } = useOpenAskAIPageInCommandMenu();
|
||||
|
||||
const handleThreadClick = (thread: AgentChatThread) => {
|
||||
setCurrentAIChatThread(thread.id);
|
||||
setCurrentAIChatThreadTitle(thread.title ?? null);
|
||||
|
||||
const hasUsageData =
|
||||
(thread.conversationSize ?? 0) > 0 &&
|
||||
@@ -103,7 +108,6 @@ export const AIChatThreadGroup = ({
|
||||
);
|
||||
|
||||
openAskAIPage({
|
||||
pageTitle: thread.title,
|
||||
resetNavigationStack: false,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
|
||||
import { useGetBrowsingContext } from '@/ai/hooks/useBrowsingContext';
|
||||
import { agentChatInputState } from '@/ai/states/agentChatInputState';
|
||||
import { agentChatSelectedFilesState } from '@/ai/states/agentChatSelectedFilesState';
|
||||
import { agentChatUploadedFilesState } from '@/ai/states/agentChatUploadedFilesState';
|
||||
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
|
||||
import { agentChatInputState } from '@/ai/states/agentChatInputState';
|
||||
import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState';
|
||||
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
|
||||
import { getTokenPair } from '@/apollo/utils/getTokenPair';
|
||||
import { renewToken } from '@/auth/services/AuthService';
|
||||
@@ -23,6 +23,9 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
|
||||
const setAgentChatUsage = useSetRecoilState(agentChatUsageState);
|
||||
|
||||
const { getBrowsingContext } = useGetBrowsingContext();
|
||||
const setCurrentAIChatThreadTitle = useSetRecoilState(
|
||||
currentAIChatThreadTitleState,
|
||||
);
|
||||
|
||||
const agentChatSelectedFiles = useRecoilValue(agentChatSelectedFilesState);
|
||||
|
||||
@@ -150,6 +153,14 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
|
||||
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,
|
||||
} from '@/ai/states/agentChatUsageState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState';
|
||||
import { isCreatingChatThreadState } from '@/ai/states/isCreatingChatThreadState';
|
||||
import { mapDBMessagesToUIMessages } from '@/ai/utils/mapDBMessagesToUIMessages';
|
||||
import {
|
||||
@@ -46,6 +47,9 @@ export const useAgentChatData = () => {
|
||||
currentAIChatThreadState,
|
||||
);
|
||||
const setAgentChatUsage = useSetRecoilState(agentChatUsageState);
|
||||
const setCurrentAIChatThreadTitle = useSetRecoilState(
|
||||
currentAIChatThreadTitleState,
|
||||
);
|
||||
const [isCreatingChatThread, setIsCreatingChatThread] = useRecoilState(
|
||||
isCreatingChatThreadState,
|
||||
);
|
||||
@@ -56,6 +60,7 @@ export const useAgentChatData = () => {
|
||||
onCompleted: (data) => {
|
||||
setIsCreatingChatThread(false);
|
||||
setCurrentAIChatThread(data.createChatThread.id);
|
||||
setCurrentAIChatThreadTitle(null);
|
||||
setAgentChatUsage(null);
|
||||
},
|
||||
onError: () => {
|
||||
@@ -70,6 +75,7 @@ export const useAgentChatData = () => {
|
||||
const firstThread = data.chatThreads[0];
|
||||
|
||||
setCurrentAIChatThread(firstThread.id);
|
||||
setCurrentAIChatThreadTitle(firstThread.title ?? null);
|
||||
setUsageFromThread(firstThread, setAgentChatUsage);
|
||||
} else if (!isCreatingChatThread) {
|
||||
setIsCreatingChatThread(true);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState';
|
||||
import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu';
|
||||
import { useRecoilState, useSetRecoilState } from 'recoil';
|
||||
import { useCreateChatThreadMutation } from '~/generated-metadata/graphql';
|
||||
@@ -7,11 +8,15 @@ import { useCreateChatThreadMutation } from '~/generated-metadata/graphql';
|
||||
export const useCreateNewAIChatThread = () => {
|
||||
const [, setCurrentAIChatThread] = useRecoilState(currentAIChatThreadState);
|
||||
const setAgentChatUsage = useSetRecoilState(agentChatUsageState);
|
||||
const setCurrentAIChatThreadTitle = useSetRecoilState(
|
||||
currentAIChatThreadTitleState,
|
||||
);
|
||||
|
||||
const { openAskAIPage } = useOpenAskAIPageInCommandMenu();
|
||||
const [createChatThread] = useCreateChatThreadMutation({
|
||||
onCompleted: (data) => {
|
||||
setCurrentAIChatThread(data.createChatThread.id);
|
||||
setCurrentAIChatThreadTitle(null);
|
||||
setAgentChatUsage(null);
|
||||
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 { OverflowingTextWithTooltip } from 'twenty-ui/display';
|
||||
|
||||
import { CommandMenuAskAIInfo } from '@/command-menu/components/CommandMenuAskAIInfo';
|
||||
import { CommandMenuFolderInfo } from '@/command-menu/components/CommandMenuFolderInfo';
|
||||
import { CommandMenuLinkInfo } from '@/command-menu/components/CommandMenuLinkInfo';
|
||||
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 (
|
||||
<StyledPageTitle>
|
||||
<OverflowingTextWithTooltip text={pageChip.text ?? ''} />
|
||||
|
||||
+81
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
+12
-7
@@ -2,21 +2,24 @@ import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
|
||||
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
|
||||
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { IconSparkles } from 'twenty-ui/display';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
export const useOpenAskAIPageInCommandMenu = () => {
|
||||
const { navigateCommandMenu } = useCommandMenu();
|
||||
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
|
||||
|
||||
const openAskAIPage = ({
|
||||
pageTitle,
|
||||
const openAskAIPage = useRecoilCallback(
|
||||
({ snapshot }) =>
|
||||
({
|
||||
resetNavigationStack,
|
||||
}: {
|
||||
pageTitle?: string | null;
|
||||
resetNavigationStack?: boolean;
|
||||
} = {}) => {
|
||||
const isCommandMenuOpened = snapshot
|
||||
.getLoadable(isCommandMenuOpenedState)
|
||||
.getValue();
|
||||
|
||||
const shouldReset =
|
||||
resetNavigationStack !== undefined
|
||||
? resetNavigationStack
|
||||
@@ -24,12 +27,14 @@ export const useOpenAskAIPageInCommandMenu = () => {
|
||||
|
||||
navigateCommandMenu({
|
||||
page: CommandMenuPages.AskAI,
|
||||
pageTitle: pageTitle ?? t`Ask AI`,
|
||||
pageTitle: t`Ask AI`,
|
||||
pageIcon: IconSparkles,
|
||||
pageId: v4(),
|
||||
resetNavigationStack: shouldReset,
|
||||
});
|
||||
};
|
||||
},
|
||||
[navigateCommandMenu],
|
||||
);
|
||||
|
||||
return {
|
||||
openAskAIPage,
|
||||
|
||||
+1
-1
@@ -86,6 +86,6 @@ describe('processSingleDrag', () => {
|
||||
recordsWithPosition: [],
|
||||
isDroppedAfterList: false,
|
||||
}),
|
||||
).toThrowError('Cannot find item to move for id : record-1');
|
||||
).toThrow('Cannot find item to move for id : record-1');
|
||||
});
|
||||
});
|
||||
|
||||
+6
-8
@@ -80,15 +80,13 @@ describe('useCreateSSOIdentityProvider', () => {
|
||||
const OTHERParams = {
|
||||
type: 'OTHER' as const,
|
||||
};
|
||||
renderHook(
|
||||
async () => {
|
||||
const { createSSOIdentityProvider } = useCreateSSOIdentityProvider();
|
||||
const { result } = renderHook(() => useCreateSSOIdentityProvider(), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
await expect(
|
||||
// @ts-expect-error - It's expected to throw an error
|
||||
createSSOIdentityProvider(OTHERParams),
|
||||
).rejects.toThrowError();
|
||||
},
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
result.current.createSSOIdentityProvider(OTHERParams),
|
||||
).rejects.toThrow('Invalid IdpType');
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ describe('getCronTriggerDefaultSettings', () => {
|
||||
|
||||
it('throws an error for an invalid interval', () => {
|
||||
// @ts-expect-error Testing invalid input
|
||||
expect(() => getCronTriggerDefaultSettings('INVALID')).toThrowError(
|
||||
expect(() => getCronTriggerDefaultSettings('INVALID')).toThrow(
|
||||
'Invalid cron trigger interval',
|
||||
);
|
||||
});
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ describe('getWebhookTriggerDefaultSettings', () => {
|
||||
|
||||
it('throws an error for an invalid http method', () => {
|
||||
// @ts-expect-error Testing invalid input
|
||||
expect(() => getWebhookTriggerDefaultSettings('INVALID')).toThrowError(
|
||||
expect(() => getWebhookTriggerDefaultSettings('INVALID')).toThrow(
|
||||
'Invalid webhook http method',
|
||||
);
|
||||
});
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,4 +65,5 @@ export type DataMessagePart = {
|
||||
};
|
||||
};
|
||||
'code-execution': CodeExecutionData;
|
||||
'thread-title': { title: string };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user