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
@@ -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 ?? ''} />
@@ -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 { 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,
resetNavigationStack,
}: {
pageTitle?: string | null;
resetNavigationStack?: boolean;
} = {}) => {
const shouldReset =
resetNavigationStack !== undefined
? resetNavigationStack
: isCommandMenuOpened;
const openAskAIPage = useRecoilCallback(
({ snapshot }) =>
({
resetNavigationStack,
}: {
resetNavigationStack?: boolean;
} = {}) => {
const isCommandMenuOpened = snapshot
.getLoadable(isCommandMenuOpenedState)
.getValue();
navigateCommandMenu({
page: CommandMenuPages.AskAI,
pageTitle: pageTitle ?? t`Ask AI`,
pageIcon: IconSparkles,
pageId: v4(),
resetNavigationStack: shouldReset,
});
};
const shouldReset =
resetNavigationStack !== undefined
? resetNavigationStack
: isCommandMenuOpened;
navigateCommandMenu({
page: CommandMenuPages.AskAI,
pageTitle: t`Ask AI`,
pageIcon: IconSparkles,
pageId: v4(),
resetNavigationStack: shouldReset,
});
},
[navigateCommandMenu],
);
return {
openAskAIPage,