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"],
|
"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 ?? ''} />
|
||||||
|
|||||||
+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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+26
-21
@@ -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,
|
||||||
|
|||||||
+1
-1
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-10
@@ -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 },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -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',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -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',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
+3
@@ -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)) {
|
||||||
|
|||||||
+18
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
+5
-13
@@ -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 };
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user