From b625bd199598ab38ffa8f43e1678e1259f11f751 Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:26:40 +0200 Subject: [PATCH] fix(ai-chat) - improvements (#22193) - remove flickering at assistant message streamed end - add copy code - leave chat history when navigating to settings Review in cubic --- .../AgentChatMessagesFetchEffect.tsx | 20 ++++- .../AgentChatStreamSubscriptionEffect.tsx | 15 +++- .../ai/components/LazyMarkdownRenderer.tsx | 5 +- .../ai/components/MarkdownCodeBlock.tsx | 80 +++++++++++++++++++ .../ai/hooks/useAgentChatSubscription.ts | 10 +++ ...ingPersistedRefetchComponentFamilyState.ts | 9 +++ .../navigation/hooks/useOpenSettings.ts | 7 ++ .../ai/ai-chat/jobs/stream-agent-chat.job.ts | 23 ++++-- 8 files changed, 155 insertions(+), 14 deletions(-) create mode 100644 packages/twenty-front/src/modules/ai/components/MarkdownCodeBlock.tsx create mode 100644 packages/twenty-front/src/modules/ai/states/agentChatIsAwaitingPersistedRefetchComponentFamilyState.ts diff --git a/packages/twenty-front/src/modules/ai/components/AgentChatMessagesFetchEffect.tsx b/packages/twenty-front/src/modules/ai/components/AgentChatMessagesFetchEffect.tsx index 6671fa5c1e..a21fa8351d 100644 --- a/packages/twenty-front/src/modules/ai/components/AgentChatMessagesFetchEffect.tsx +++ b/packages/twenty-front/src/modules/ai/components/AgentChatMessagesFetchEffect.tsx @@ -1,13 +1,14 @@ -import { useCallback, useMemo } from 'react'; import { useStore } from 'jotai'; +import { useCallback, useMemo } from 'react'; import { type AgentChatSubscriptionEvent } from 'twenty-shared/ai'; import { isDefined } from 'twenty-shared/utils'; import { AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME } from '@/ai/constants/AgentChatRefetchMessagesEventName'; -import { agentChatFirstLiveSeqComponentFamilyState } from '@/ai/states/agentChatFirstLiveSeqComponentFamilyState'; -import { agentChatHandleEventCallbackComponentFamilyState } from '@/ai/states/agentChatHandleEventCallbackComponentFamilyState'; import { AGENT_CHAT_NEW_THREAD_DRAFT_KEY } from '@/ai/states/agentChatDraftsByThreadIdState'; import { agentChatFetchedMessagesComponentFamilyState } from '@/ai/states/agentChatFetchedMessagesComponentFamilyState'; +import { agentChatFirstLiveSeqComponentFamilyState } from '@/ai/states/agentChatFirstLiveSeqComponentFamilyState'; +import { agentChatHandleEventCallbackComponentFamilyState } from '@/ai/states/agentChatHandleEventCallbackComponentFamilyState'; +import { agentChatIsAwaitingPersistedRefetchComponentFamilyState } from '@/ai/states/agentChatIsAwaitingPersistedRefetchComponentFamilyState'; import { agentChatMessagesLoadingState } from '@/ai/states/agentChatMessagesLoadingState'; import { agentChatQueuedMessagesComponentFamilyState } from '@/ai/states/agentChatQueuedMessagesComponentFamilyState'; import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState'; @@ -53,6 +54,11 @@ export const AgentChatMessagesFetchEffect = () => { { threadId: currentAiChatThread }, ); + const setAgentChatIsAwaitingPersistedRefetch = useSetAtomComponentFamilyState( + agentChatIsAwaitingPersistedRefetchComponentFamilyState, + { threadId: currentAiChatThread }, + ); + const handleEventCallbackFamilyCallback = useAtomComponentFamilyStateCallbackState( agentChatHandleEventCallbackComponentFamilyState, @@ -77,6 +83,7 @@ export const AgentChatMessagesFetchEffect = () => { setAgentChatQueuedMessages( uiMessages.filter((message) => message.status === 'queued'), ); + setAgentChatIsAwaitingPersistedRefetch(false); const catchup = data.chatStreamCatchupChunks; @@ -119,6 +126,7 @@ export const AgentChatMessagesFetchEffect = () => { [ setAgentChatFetchedMessages, setAgentChatQueuedMessages, + setAgentChatIsAwaitingPersistedRefetch, store, handleEventCallbackFamilyCallback, firstLiveSeqFamilyCallback, @@ -128,8 +136,12 @@ export const AgentChatMessagesFetchEffect = () => { const handleLoadingChange = useCallback( (loading: boolean) => { setAgentChatMessagesLoading(loading); + + if (!loading) { + setAgentChatIsAwaitingPersistedRefetch(false); + } }, - [setAgentChatMessagesLoading], + [setAgentChatMessagesLoading, setAgentChatIsAwaitingPersistedRefetch], ); const { refetch: refetchAgentChatMessages } = useQueryWithCallbacks( diff --git a/packages/twenty-front/src/modules/ai/components/AgentChatStreamSubscriptionEffect.tsx b/packages/twenty-front/src/modules/ai/components/AgentChatStreamSubscriptionEffect.tsx index aecb63d8cd..dde5623e6d 100644 --- a/packages/twenty-front/src/modules/ai/components/AgentChatStreamSubscriptionEffect.tsx +++ b/packages/twenty-front/src/modules/ai/components/AgentChatStreamSubscriptionEffect.tsx @@ -9,6 +9,7 @@ import { useEnsureAgentChatThreadExistsForDraft } from '@/ai/hooks/useEnsureAgen import { useEnsureAgentChatThreadIdForSend } from '@/ai/hooks/useEnsureAgentChatThreadIdForSend'; import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState'; import { agentChatFetchedMessagesComponentFamilyState } from '@/ai/states/agentChatFetchedMessagesComponentFamilyState'; +import { agentChatIsAwaitingPersistedRefetchComponentFamilyState } from '@/ai/states/agentChatIsAwaitingPersistedRefetchComponentFamilyState'; import { agentChatIsInitialScrollPendingOnThreadChangeState } from '@/ai/states/agentChatIsInitialScrollPendingOnThreadChangeState'; import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState'; import { agentChatIsStreamingComponentFamilyState } from '@/ai/states/agentChatIsStreamingComponentFamilyState'; @@ -62,6 +63,11 @@ export const AgentChatStreamSubscriptionEffect = () => { { threadId: currentAiChatThread }, ); + const agentChatIsAwaitingPersistedRefetch = useAtomComponentFamilyStateValue( + agentChatIsAwaitingPersistedRefetchComponentFamilyState, + { threadId: currentAiChatThread }, + ); + const agentChatDisplayedThread = useAtomStateValue( agentChatDisplayedThreadState, ); @@ -79,9 +85,15 @@ export const AgentChatStreamSubscriptionEffect = () => { return; } + const isThreadSwitch = currentAiChatThread !== agentChatDisplayedThread; + + if (!isThreadSwitch && agentChatIsAwaitingPersistedRefetch) { + return; + } + setAgentChatMessages(agentChatFetchedMessages); - if (currentAiChatThread !== agentChatDisplayedThread) { + if (isThreadSwitch) { if (agentChatFetchedMessages.length > 0) { setAgentChatIsInitialScrollPendingOnThreadChange(true); } @@ -90,6 +102,7 @@ export const AgentChatStreamSubscriptionEffect = () => { }, [ agentChatFetchedMessages, agentChatIsStreaming, + agentChatIsAwaitingPersistedRefetch, setAgentChatMessages, currentAiChatThread, agentChatDisplayedThread, diff --git a/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx b/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx index ecf31f2da7..168b6ae77c 100644 --- a/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx +++ b/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx @@ -10,6 +10,7 @@ import { StyledSkeletonContainer, StyledTableScrollContainer, } from '@/ai/components/LazyMarkdownRendererStyledComponents'; +import { MarkdownCodeBlock } from '@/ai/components/MarkdownCodeBlock'; import { cloneElement, isValidElement, @@ -158,9 +159,7 @@ const MarkdownRenderer = lazy(async () => { children?: React.ReactNode; }) => {children}, pre: ({ children }) => ( -
-
{children}
-
+ {children} ), }} > diff --git a/packages/twenty-front/src/modules/ai/components/MarkdownCodeBlock.tsx b/packages/twenty-front/src/modules/ai/components/MarkdownCodeBlock.tsx new file mode 100644 index 0000000000..a89cba04cc --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/MarkdownCodeBlock.tsx @@ -0,0 +1,80 @@ +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { isNumber, isString } from '@sniptt/guards'; +import { Children, isValidElement } from 'react'; +import { IconCopy } from 'twenty-ui/icon'; +import { LightIconButton } from 'twenty-ui/input'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { useCopyToClipboard } from '~/hooks/useCopyToClipboard'; + +const StyledContainer = styled.div` + position: relative; + + .markdown-block-code { + margin: 0; + } +`; + +const StyledCopyButtonContainer = styled.div` + align-items: center; + border-radius: ${themeCssVariables.border.radius.sm}; + display: flex; + opacity: 0; + position: absolute; + right: ${themeCssVariables.spacing[2]}; + top: ${themeCssVariables.spacing[2]}; + transition: opacity calc(${themeCssVariables.animation.duration.fast} * 1s) + ease; + + ${StyledContainer}:hover & { + opacity: 1; + } + + &:hover { + background: ${themeCssVariables.background.primary}; + } +`; + +const extractTextFromNode = (node: React.ReactNode): string => { + if (isString(node) || isNumber(node)) { + return node.toString(); + } + + if (Array.isArray(node)) { + return node.map(extractTextFromNode).join(''); + } + + if (isValidElement<{ children?: React.ReactNode }>(node)) { + return Children.toArray(node.props.children) + .map(extractTextFromNode) + .join(''); + } + + return ''; +}; + +export const MarkdownCodeBlock = ({ + children, +}: { + children: React.ReactNode; +}) => { + const { t } = useLingui(); + const { copyToClipboard } = useCopyToClipboard(); + + const codeText = extractTextFromNode(children); + + return ( + + + copyToClipboard(codeText, t`Code copied to clipboard`)} + title={t`Copy code`} + size="small" + accent="tertiary" + /> + +
{children}
+
+ ); +}; diff --git a/packages/twenty-front/src/modules/ai/hooks/useAgentChatSubscription.ts b/packages/twenty-front/src/modules/ai/hooks/useAgentChatSubscription.ts index d17d96351d..1b770c3992 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useAgentChatSubscription.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useAgentChatSubscription.ts @@ -15,6 +15,7 @@ import { ON_AGENT_CHAT_EVENT } from '@/ai/graphql/subscriptions/OnAgentChatEvent import { agentChatErrorComponentFamilyState } from '@/ai/states/agentChatErrorComponentFamilyState'; import { agentChatFirstLiveSeqComponentFamilyState } from '@/ai/states/agentChatFirstLiveSeqComponentFamilyState'; import { agentChatHandleEventCallbackComponentFamilyState } from '@/ai/states/agentChatHandleEventCallbackComponentFamilyState'; +import { agentChatIsAwaitingPersistedRefetchComponentFamilyState } from '@/ai/states/agentChatIsAwaitingPersistedRefetchComponentFamilyState'; import { agentChatIsStreamingComponentFamilyState } from '@/ai/states/agentChatIsStreamingComponentFamilyState'; import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState'; import { agentChatUsageComponentFamilyState } from '@/ai/states/agentChatUsageComponentFamilyState'; @@ -108,6 +109,10 @@ export const useAgentChatSubscription = (threadId: string | null) => { const firstLiveSeqFamilyCallback = useAtomComponentFamilyStateCallbackState( agentChatFirstLiveSeqComponentFamilyState, ); + const isAwaitingPersistedRefetchFamilyCallback = + useAtomComponentFamilyStateCallbackState( + agentChatIsAwaitingPersistedRefetchComponentFamilyState, + ); const handleEventCallbackFamilyCallback = useAtomComponentFamilyStateCallbackState( agentChatHandleEventCallbackComponentFamilyState, @@ -132,6 +137,8 @@ export const useAgentChatSubscription = (threadId: string | null) => { const errorAtom = errorFamilyCallback(familyKey); const isStreamingAtom = isStreamingFamilyCallback(familyKey); const firstLiveSeqAtom = firstLiveSeqFamilyCallback(familyKey); + const isAwaitingPersistedRefetchAtom = + isAwaitingPersistedRefetchFamilyCallback(familyKey); const handleEventCallbackAtom = handleEventCallbackFamilyCallback(familyKey); const messagesAtom = messagesFamilyCallback(familyKey); @@ -303,6 +310,7 @@ export const useAgentChatSubscription = (threadId: string | null) => { case 'message-persisted': { closeWriter(); + store.set(isAwaitingPersistedRefetchAtom, true); dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME); break; } @@ -363,6 +371,7 @@ export const useAgentChatSubscription = (threadId: string | null) => { store.set(errorAtom, noMoreCreditsError); closeWriter(); + store.set(isAwaitingPersistedRefetchAtom, true); dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME); store.set(isStreamingAtom, false); break; @@ -412,6 +421,7 @@ export const useAgentChatSubscription = (threadId: string | null) => { errorFamilyCallback, isStreamingFamilyCallback, firstLiveSeqFamilyCallback, + isAwaitingPersistedRefetchFamilyCallback, handleEventCallbackFamilyCallback, messagesFamilyCallback, usageFamilyCallback, diff --git a/packages/twenty-front/src/modules/ai/states/agentChatIsAwaitingPersistedRefetchComponentFamilyState.ts b/packages/twenty-front/src/modules/ai/states/agentChatIsAwaitingPersistedRefetchComponentFamilyState.ts new file mode 100644 index 0000000000..7674fb040a --- /dev/null +++ b/packages/twenty-front/src/modules/ai/states/agentChatIsAwaitingPersistedRefetchComponentFamilyState.ts @@ -0,0 +1,9 @@ +import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext'; +import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilyState'; + +export const agentChatIsAwaitingPersistedRefetchComponentFamilyState = + createAtomComponentFamilyState({ + key: 'agentChatIsAwaitingPersistedRefetchComponentFamilyState', + defaultValue: false, + componentInstanceContext: AgentChatComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/navigation/hooks/useOpenSettings.ts b/packages/twenty-front/src/modules/navigation/hooks/useOpenSettings.ts index 48522dfa33..c7babc6e0f 100644 --- a/packages/twenty-front/src/modules/navigation/hooks/useOpenSettings.ts +++ b/packages/twenty-front/src/modules/navigation/hooks/useOpenSettings.ts @@ -2,6 +2,8 @@ import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage'; import { currentMobileNavigationDrawerState } from '@/navigation/states/currentMobileNavigationDrawerState'; import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded'; import { navigationDrawerExpandedMemorizedState } from '@/ui/navigation/states/navigationDrawerExpandedMemorizedState'; +import { navigationDrawerActiveTabState } from '@/ui/navigation/states/navigationDrawerActiveTabState'; +import { NAVIGATION_DRAWER_TABS } from '@/ui/navigation/states/navigationDrawerTabs'; import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState'; import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; @@ -22,6 +24,9 @@ export const useOpenSettingsMenu = () => { const setCurrentMobileNavigationDrawer = useSetAtomState( currentMobileNavigationDrawerState, ); + const setNavigationDrawerActiveTab = useSetAtomState( + navigationDrawerActiveTabState, + ); const openSettingsMenu = useCallback(() => { if (isSettingsPage) { @@ -32,6 +37,7 @@ export const useOpenSettingsMenu = () => { setIsNavigationDrawerExpanded(true); setNavigationMemorizedUrl(location.pathname + location.search); setCurrentMobileNavigationDrawer('settings'); + setNavigationDrawerActiveTab(NAVIGATION_DRAWER_TABS.NAVIGATION_MENU); }, [ isSettingsPage, isNavigationDrawerExpanded, @@ -41,6 +47,7 @@ export const useOpenSettingsMenu = () => { setIsNavigationDrawerExpanded, setNavigationDrawerExpandedMemorized, setNavigationMemorizedUrl, + setNavigationDrawerActiveTab, ]); return { openSettingsMenu }; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/jobs/stream-agent-chat.job.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/jobs/stream-agent-chat.job.ts index b55ab0753b..c35ebf57b8 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/jobs/stream-agent-chat.job.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/jobs/stream-agent-chat.job.ts @@ -1,18 +1,17 @@ import { Logger, Scope } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { isNonEmptyString } from '@sniptt/guards'; import { createUIMessageStream } from 'ai'; import type { CodeExecutionData, ExtendedUIMessage, ExtendedUIMessagePart, } from 'twenty-shared/ai'; -import { isNonEmptyString } from '@sniptt/guards'; -import { Repository } from 'typeorm'; import { isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; +import { v4 } from 'uuid'; -import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; -import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; @@ -30,6 +29,8 @@ import { AgentChatService } from 'src/engine/metadata-modules/ai/ai-chat/service import { ChatExecutionService } from 'src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service'; import { getCancelChannel } from 'src/engine/metadata-modules/ai/ai-chat/utils/get-cancel-channel.util'; import type { AiModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-config.type'; +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { STREAM_AGENT_CHAT_JOB_NAME } from './stream-agent-chat-job-name.constant'; import { type StreamAgentChatJobData } from './stream-agent-chat-job.types'; @@ -184,6 +185,8 @@ export class StreamAgentChatJob { abortSignal: AbortSignal; }): Promise { return new Promise((resolve, reject) => { + const assistantMessageId = v4(); + let streamUsage = { inputTokens: 0, outputTokens: 0, @@ -259,7 +262,8 @@ export class StreamAgentChatJob { return error instanceof Error ? error.message : String(error); }, - sendStart: false, + sendStart: true, + generateMessageId: () => assistantMessageId, messageMetadata: ({ part }) => { return this.computeMessageMetadata({ part, @@ -280,6 +284,7 @@ export class StreamAgentChatJob { onFinish: async ({ responseMessage, isAborted }) => { try { await this.handleStreamFinish({ + assistantMessageId, responseMessage, isAborted, streamError, @@ -335,7 +340,10 @@ export class StreamAgentChatJob { await this.eventPublisherService.publish({ threadId: data.threadId, workspaceId: data.workspaceId, - event: { type: 'message-persisted', messageId: data.threadId }, + event: { + type: 'message-persisted', + messageId: assistantMessageId, + }, }); resolve(); } @@ -435,6 +443,7 @@ export class StreamAgentChatJob { } private async handleStreamFinish({ + assistantMessageId, responseMessage, isAborted, streamError, @@ -448,6 +457,7 @@ export class StreamAgentChatJob { modelConfig, userMessagePromise, }: { + assistantMessageId: string; responseMessage: Omit; isAborted: boolean; streamError: unknown; @@ -502,6 +512,7 @@ export class StreamAgentChatJob { await this.agentChatService.addMessage({ threadId, uiMessage: responseMessage, + id: assistantMessageId, turnId: userMessage.turnId ?? undefined, workspaceId, });