mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
Fix AI chat re-renders and refactored code (#18585)
This PR: - Breaks useAgentChatData into focused effect components (streaming, fetch, init, auto-scroll, diff sync) - Splits message list into non-last (stable) + last (streaming/error) to prevent full re-renders on each stream chunk - Adds scroll-to-bottom button and MutationObserver-based auto-scroll on thread switch - Lifts loading state from context to atoms - Adds areEqual to selector factories We could improve further but this sets up a robust architecture for further refactoring. ## Messages flow The flow of messages loading and streaming is now more solid. Everything goes out from `AgentChatAiSdkStreamEffect`, whether loaded from the DB or streaming directly, and every consumers is using only one atom `agentChatMessagesComponentFamilyState` ## Data sync effect with callbacks new hook See `packages/twenty-front/src/modules/apollo/hooks/useQueryWithCallbacks.ts` which allows to fix Apollo v4 migration leftovers and is an implementation of the pattern we talked about with @charlesBochet We could refine this pattern in another PR. # Before https://github.com/user-attachments/assets/84e7a96f-6790-405d-8a73-2dacbf783be5 # After https://github.com/user-attachments/assets/4c692e3a-2413-4513-abcc-44d0da311203 Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
parent
d389a4341d
commit
fc9723949b
65 changed files with 1299 additions and 567 deletions
7
.claude/settings.json
Normal file
7
.claude/settings.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git stash:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -2,8 +2,8 @@
|
|||
"mcpServers": {
|
||||
"postgres": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-postgres", "${PG_DATABASE_URL}"],
|
||||
"command": "bash",
|
||||
"args": ["-c", "source packages/twenty-server/.env && npx -y @modelcontextprotocol/server-postgres \"$PG_DATABASE_URL\""],
|
||||
"env": {}
|
||||
},
|
||||
"playwright": {
|
||||
|
|
|
|||
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -80,6 +80,17 @@ npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/mi
|
|||
npx nx run twenty-server:command workspace:sync-metadata
|
||||
```
|
||||
|
||||
### Database Inspection (Postgres MCP)
|
||||
|
||||
A read-only Postgres MCP server is configured in `.mcp.json`. Use it to:
|
||||
- Inspect workspace data, metadata, and object definitions while developing
|
||||
- Verify migration results (columns, types, constraints) after running migrations
|
||||
- Explore the multi-tenant schema structure (core, metadata, workspace-specific schemas)
|
||||
- Debug issues by querying raw data to confirm whether a bug is frontend, backend, or data-level
|
||||
- Inspect metadata tables to debug GraphQL schema generation or `workspace:sync-metadata` issues
|
||||
|
||||
This server is read-only — for write operations (reset, migrations, sync), use the CLI commands above.
|
||||
|
||||
### GraphQL
|
||||
```bash
|
||||
# Generate GraphQL types (run after schema changes)
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ import { type Editor } from '@tiptap/react';
|
|||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { AIChatSuggestedPrompts } from '@/ai/components/suggested-prompts/AIChatSuggestedPrompts';
|
||||
import { useAgentChatContext } from '@/ai/contexts/AgentChatContext';
|
||||
import { AGENT_CHAT_NEW_THREAD_DRAFT_KEY } from '@/ai/states/agentChatDraftsByThreadIdState';
|
||||
import { agentChatMessagesLoadingState } from '@/ai/states/agentChatMessagesLoadingState';
|
||||
import { agentChatThreadsLoadingState } from '@/ai/states/agentChatThreadsLoadingState';
|
||||
import { agentChatErrorState } from '@/ai/states/agentChatErrorState';
|
||||
import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
|
|
@ -26,7 +27,12 @@ type AIChatEmptyStateProps = {
|
|||
|
||||
export const AIChatEmptyState = ({ editor }: AIChatEmptyStateProps) => {
|
||||
const agentChatError = useAtomStateValue(agentChatErrorState);
|
||||
const { threadsLoading, messagesLoading } = useAgentChatContext();
|
||||
const agentChatThreadsLoading = useAtomStateValue(
|
||||
agentChatThreadsLoadingState,
|
||||
);
|
||||
const agentChatMessagesLoading = useAtomStateValue(
|
||||
agentChatMessagesLoadingState,
|
||||
);
|
||||
const skipMessagesSkeletonUntilLoaded = useAtomStateValue(
|
||||
skipMessagesSkeletonUntilLoadedState,
|
||||
);
|
||||
|
|
@ -37,11 +43,10 @@ export const AIChatEmptyState = ({ editor }: AIChatEmptyStateProps) => {
|
|||
);
|
||||
|
||||
const isOnNewChatSlot =
|
||||
!isDefined(currentAIChatThread) ||
|
||||
currentAIChatThread === AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
|
||||
const skeletonShowing =
|
||||
(threadsLoading && isOnNewChatSlot) ||
|
||||
(messagesLoading && !skipMessagesSkeletonUntilLoaded);
|
||||
(agentChatThreadsLoading && isOnNewChatSlot) ||
|
||||
(agentChatMessagesLoading && !skipMessagesSkeletonUntilLoaded);
|
||||
const shouldRender =
|
||||
!hasMessages && !isDefined(agentChatError) && !skeletonShowing;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import { AIChatMessage } from '@/ai/components/AIChatMessage';
|
||||
import { agentChatErrorState } from '@/ai/states/agentChatErrorState';
|
||||
import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState';
|
||||
import { agentChatLastMessageIdComponentSelector } from '@/ai/states/agentChatLastMessageIdComponentSelector';
|
||||
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const AIChatLastMessageWithStreamingState = () => {
|
||||
const lastMessageId = useAtomComponentSelectorValue(
|
||||
agentChatLastMessageIdComponentSelector,
|
||||
);
|
||||
|
||||
const agentChatIsStreaming = useAtomStateValue(agentChatIsStreamingState);
|
||||
const agentChatError = useAtomStateValue(agentChatErrorState);
|
||||
|
||||
if (!isDefined(lastMessageId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AIChatMessage
|
||||
messageId={lastMessageId}
|
||||
isLastMessageStreaming={agentChatIsStreaming}
|
||||
error={agentChatError ?? undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -5,13 +5,9 @@ import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
|
|||
|
||||
import { AIChatAssistantMessageRenderer } from '@/ai/components/AIChatAssistantMessageRenderer';
|
||||
import { AIChatErrorRenderer } from '@/ai/components/AIChatErrorRenderer';
|
||||
import { agentChatErrorState } from '@/ai/states/agentChatErrorState';
|
||||
import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState';
|
||||
import { agentChatMessageComponentFamilySelector } from '@/ai/states/agentChatMessageComponentFamilySelector';
|
||||
import { agentChatMessageIdsComponentSelector } from '@/ai/states/agentChatMessageIdsComponentSelector';
|
||||
import { LightCopyIconButton } from '@/object-record/record-field/ui/components/LightCopyIconButton';
|
||||
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
|
||||
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
|
||||
import { isExtendedFileUIPart } from 'twenty-shared/ai';
|
||||
|
|
@ -143,39 +139,37 @@ const StyledFilesContainer = styled.div`
|
|||
margin-top: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
export const AIChatMessage = ({ messageId }: { messageId: string }) => {
|
||||
type AIChatMessageProps = {
|
||||
messageId: string;
|
||||
isLastMessageStreaming?: boolean;
|
||||
error?: Error | undefined;
|
||||
};
|
||||
|
||||
export const AIChatMessage = ({
|
||||
messageId,
|
||||
isLastMessageStreaming = false,
|
||||
error,
|
||||
}: AIChatMessageProps) => {
|
||||
const agentChatMessage = useAtomComponentFamilySelectorValue(
|
||||
agentChatMessageComponentFamilySelector,
|
||||
{ messageId },
|
||||
);
|
||||
|
||||
const agentChatMessageIds = useAtomComponentSelectorValue(
|
||||
agentChatMessageIdsComponentSelector,
|
||||
);
|
||||
|
||||
const agentChatIsStreaming = useAtomStateValue(agentChatIsStreamingState);
|
||||
|
||||
const agentChatError = useAtomStateValue(agentChatErrorState);
|
||||
|
||||
const { localeCatalog } = useAtomStateValue(dateLocaleState);
|
||||
|
||||
if (!isDefined(agentChatMessage)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isLastMessage = agentChatMessageIds.at(-1) === messageId;
|
||||
|
||||
const isLastMessageStreaming = agentChatIsStreaming && isLastMessage;
|
||||
const isLastAssistantMessage =
|
||||
isLastMessage && agentChatMessage?.role === AgentMessageRole.ASSISTANT;
|
||||
const shouldShowError = isDefined(agentChatError) && isLastAssistantMessage;
|
||||
|
||||
const isUser = agentChatMessage.role === AgentMessageRole.USER;
|
||||
const isLastAssistantMessage =
|
||||
agentChatMessage.role === AgentMessageRole.ASSISTANT;
|
||||
const shouldShowError = isDefined(error) && isLastAssistantMessage;
|
||||
|
||||
const fileParts = agentChatMessage.parts.filter(isExtendedFileUIPart);
|
||||
|
||||
return (
|
||||
<StyledMessageBubble key={agentChatMessage.id} isUser={isUser}>
|
||||
<StyledMessageBubble isUser={isUser}>
|
||||
<StyledMessageContainer isUser={isUser}>
|
||||
<StyledMessageText isUser={isUser}>
|
||||
<AIChatAssistantMessageRenderer
|
||||
|
|
@ -191,7 +185,9 @@ export const AIChatMessage = ({ messageId }: { messageId: string }) => {
|
|||
))}
|
||||
</StyledFilesContainer>
|
||||
)}
|
||||
{shouldShowError && <AIChatErrorRenderer error={agentChatError} />}
|
||||
{shouldShowError && isDefined(error) && (
|
||||
<AIChatErrorRenderer error={error} />
|
||||
)}
|
||||
</StyledMessageContainer>
|
||||
{agentChatMessage.parts.length > 0 &&
|
||||
agentChatMessage.metadata?.createdAt && (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import { AIChatMessage } from '@/ai/components/AIChatMessage';
|
||||
import { agentChatNonLastMessageIdsComponentSelector } from '@/ai/states/agentChatNonLastMessageIdsComponentSelector';
|
||||
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
|
||||
|
||||
export const AIChatNonLastMessageIdsList = () => {
|
||||
const agentChatNonLastMessageIds = useAtomComponentSelectorValue(
|
||||
agentChatNonLastMessageIdsComponentSelector,
|
||||
);
|
||||
|
||||
return agentChatNonLastMessageIds.map((messageId) => (
|
||||
<AIChatMessage key={messageId} messageId={messageId} />
|
||||
));
|
||||
};
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { agentChatIsScrolledToBottomSelector } from '@/ai/states/agentChatIsScrolledToBottomSelector';
|
||||
import { scrollAIChatToBottom } from '@/ai/utils/scrollAIChatToBottom';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { IconArrowDown } from 'twenty-ui/display';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledScrollToBottomButton = styled.button<{ isVisible: boolean }>`
|
||||
align-items: center;
|
||||
background: ${themeCssVariables.background.primary};
|
||||
border: 1px solid ${themeCssVariables.border.color.medium};
|
||||
border-radius: ${themeCssVariables.border.radius.rounded};
|
||||
bottom: ${themeCssVariables.spacing[3]};
|
||||
box-shadow: ${themeCssVariables.boxShadow.light};
|
||||
color: ${themeCssVariables.font.color.secondary};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
height: 32px;
|
||||
justify-content: center;
|
||||
left: 50%;
|
||||
opacity: ${({ isVisible }) => (isVisible ? 1 : 0)};
|
||||
pointer-events: ${({ isVisible }) => (isVisible ? 'auto' : 'none')};
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
transition:
|
||||
opacity calc(${themeCssVariables.animation.duration.normal} * 1s) ease,
|
||||
background calc(${themeCssVariables.animation.duration.fast} * 1s) ease;
|
||||
width: 32px;
|
||||
z-index: 1;
|
||||
|
||||
&:hover {
|
||||
background: ${themeCssVariables.background.tertiary};
|
||||
}
|
||||
`;
|
||||
|
||||
export const AIChatScrollToBottomButton = () => {
|
||||
const agentChatIsScrolledToBottom = useAtomStateValue(
|
||||
agentChatIsScrolledToBottomSelector,
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledScrollToBottomButton
|
||||
isVisible={!agentChatIsScrolledToBottom}
|
||||
onClick={scrollAIChatToBottom}
|
||||
>
|
||||
<IconArrowDown size={16} />
|
||||
</StyledScrollToBottomButton>
|
||||
);
|
||||
};
|
||||
|
|
@ -28,7 +28,7 @@ export const AIChatTab = () => {
|
|||
const threadIdCreatedFromDraft = useAtomStateValue(
|
||||
threadIdCreatedFromDraftState,
|
||||
);
|
||||
const draftKey = currentAIChatThread ?? AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
|
||||
const draftKey = currentAIChatThread;
|
||||
const editorSectionKey =
|
||||
draftKey !== AGENT_CHAT_NEW_THREAD_DRAFT_KEY &&
|
||||
draftKey === threadIdCreatedFromDraft
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import { AIChatErrorUnderMessageList } from '@/ai/components/AIChatErrorUnderMessageList';
|
||||
import { AIChatMessage } from '@/ai/components/AIChatMessage';
|
||||
import { AIChatLastMessageWithStreamingState } from '@/ai/components/AIChatLastMessageWithStreamingState';
|
||||
import { AIChatNonLastMessageIdsList } from '@/ai/components/AIChatNonLastMessageIdsList';
|
||||
import { AIChatScrollToBottomButton } from '@/ai/components/AIChatScrollToBottomButton';
|
||||
import { AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect } from '@/ai/components/AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect';
|
||||
import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId';
|
||||
import { agentChatMessageIdsComponentSelector } from '@/ai/states/agentChatMessageIdsComponentSelector';
|
||||
import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector';
|
||||
import { agentChatIsInitialScrollPendingOnThreadChangeState } from '@/ai/states/agentChatIsInitialScrollPendingOnThreadChangeState';
|
||||
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { isNonEmptyArray } from '@sniptt/guards';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledScrollWrapperContainer = styled.div`
|
||||
|
|
@ -15,28 +19,38 @@ const StyledScrollWrapperContainer = styled.div`
|
|||
gap: ${themeCssVariables.spacing[2]};
|
||||
overflow-y: auto;
|
||||
padding: ${themeCssVariables.spacing[3]};
|
||||
position: relative;
|
||||
width: calc(100% - 24px);
|
||||
`;
|
||||
|
||||
export const AIChatTabMessageList = () => {
|
||||
const agentChatMessageIdsComponent = useAtomComponentSelectorValue(
|
||||
agentChatMessageIdsComponentSelector,
|
||||
const agentChatHasMessage = useAtomComponentSelectorValue(
|
||||
agentChatHasMessageComponentSelector,
|
||||
);
|
||||
|
||||
const hasMessages = isNonEmptyArray(agentChatMessageIdsComponent);
|
||||
const agentChatIsInitialScrollPendingOnThreadChange = useAtomStateValue(
|
||||
agentChatIsInitialScrollPendingOnThreadChangeState,
|
||||
);
|
||||
|
||||
if (!hasMessages) {
|
||||
if (!agentChatHasMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledScrollWrapperContainer>
|
||||
<StyledScrollWrapperContainer
|
||||
style={{
|
||||
visibility: agentChatIsInitialScrollPendingOnThreadChange
|
||||
? 'hidden'
|
||||
: 'visible',
|
||||
}}
|
||||
>
|
||||
<ScrollWrapper componentInstanceId={AI_CHAT_SCROLL_WRAPPER_ID}>
|
||||
{agentChatMessageIdsComponent.map((messageId) => {
|
||||
return <AIChatMessage messageId={messageId} key={messageId} />;
|
||||
})}
|
||||
<AIChatNonLastMessageIdsList />
|
||||
<AIChatLastMessageWithStreamingState />
|
||||
<AIChatErrorUnderMessageList />
|
||||
<AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect />
|
||||
</ScrollWrapper>
|
||||
<AIChatScrollToBottomButton />
|
||||
</StyledScrollWrapperContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { styled } from '@linaria/react';
|
||||
|
||||
import { AIChatThreadGroup } from '@/ai/components/AIChatThreadGroup';
|
||||
import { AIChatThreadsListEffect } from '@/ai/components/AIChatThreadsListEffect';
|
||||
import { AIChatThreadsListFocusEffect } from '@/ai/components/AIChatThreadsListFocusEffect';
|
||||
import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader';
|
||||
import { useChatThreads } from '@/ai/hooks/useChatThreads';
|
||||
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
|
||||
import { useSwitchToNewAIChat } from '@/ai/hooks/useSwitchToNewAIChat';
|
||||
import { groupThreadsByDate } from '@/ai/utils/groupThreadsByDate';
|
||||
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
|
||||
import { t } from '@lingui/core/macro';
|
||||
|
|
@ -36,7 +36,7 @@ const StyledButtonsContainer = styled.div`
|
|||
`;
|
||||
|
||||
export const AIChatThreadsList = () => {
|
||||
const { switchToNewChat } = useCreateNewAIChatThread();
|
||||
const { switchToNewChat } = useSwitchToNewAIChat();
|
||||
|
||||
const focusId = 'threads-list';
|
||||
|
||||
|
|
@ -57,7 +57,7 @@ export const AIChatThreadsList = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<AIChatThreadsListEffect focusId={focusId} />
|
||||
<AIChatThreadsListFocusEffect focusId={focusId} />
|
||||
<StyledContainer>
|
||||
<StyledThreadsContainer>
|
||||
{Object.entries(groupedThreads).map(([title, threadsInGroup]) => (
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@ import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks
|
|||
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const AIChatThreadsListEffect = ({ focusId }: { focusId: string }) => {
|
||||
export const AIChatThreadsListFocusEffect = ({
|
||||
focusId,
|
||||
}: {
|
||||
focusId: string;
|
||||
}) => {
|
||||
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
|
||||
const { removeFocusItemFromFocusStackById } =
|
||||
useRemoveFocusItemFromFocusStackById();
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import { AGENT_CHAT_ENSURE_THREAD_FOR_DRAFT_EVENT_NAME } from '@/ai/constants/AgentChatEnsureThreadForDraftEventName';
|
||||
import { useAgentChat } from '@/ai/hooks/useAgentChat';
|
||||
import { useCreateAgentChatThread } from '@/ai/hooks/useCreateAgentChatThread';
|
||||
import { useEnsureAgentChatThreadExistsForDraft } from '@/ai/hooks/useEnsureAgentChatThreadExistsForDraft';
|
||||
import { useEnsureAgentChatThreadIdForSend } from '@/ai/hooks/useEnsureAgentChatThreadIdForSend';
|
||||
import { agentChatErrorState } from '@/ai/states/agentChatErrorState';
|
||||
import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState';
|
||||
import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState';
|
||||
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
|
||||
import { agentChatMessagesLoadingState } from '@/ai/states/agentChatMessagesLoadingState';
|
||||
import { agentChatThreadsLoadingState } from '@/ai/states/agentChatThreadsLoadingState';
|
||||
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
|
||||
import { agentChatFetchedMessagesComponentFamilyState } from '@/ai/states/agentChatFetchedMessagesComponentFamilyState';
|
||||
import { agentChatIsInitialScrollPendingOnThreadChangeState } from '@/ai/states/agentChatIsInitialScrollPendingOnThreadChangeState';
|
||||
import { mergeAgentChatFetchedAndStreamingMessages } from '@/ai/utils/mergeAgentChatFetchedAndStreamingMessages';
|
||||
import { AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME } from '@/ai/constants/AgentChatRefetchMessagesEventName';
|
||||
import { dispatchBrowserEvent } from '@/browser-event/utils/dispatchBrowserEvent';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { useListenToBrowserEvent } from '@/browser-event/hooks/useListenToBrowserEvent';
|
||||
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomComponentFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentFamilyState';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
export const AgentChatAiSdkStreamEffect = () => {
|
||||
const currentAIChatThread = useAtomStateValue(currentAIChatThreadState);
|
||||
const agentChatFetchedMessages = useAtomComponentFamilyStateValue(
|
||||
agentChatFetchedMessagesComponentFamilyState,
|
||||
{ threadId: currentAIChatThread },
|
||||
);
|
||||
|
||||
const { createChatThread } = useCreateAgentChatThread();
|
||||
|
||||
const { ensureThreadExistsForDraft } =
|
||||
useEnsureAgentChatThreadExistsForDraft(createChatThread);
|
||||
|
||||
const { ensureThreadIdForSend } =
|
||||
useEnsureAgentChatThreadIdForSend(createChatThread);
|
||||
|
||||
useListenToBrowserEvent({
|
||||
eventName: AGENT_CHAT_ENSURE_THREAD_FOR_DRAFT_EVENT_NAME,
|
||||
onBrowserEvent: ensureThreadExistsForDraft,
|
||||
});
|
||||
|
||||
const onStreamingComplete = useCallback(() => {
|
||||
dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME);
|
||||
}, []);
|
||||
|
||||
const chatState = useAgentChat(
|
||||
agentChatFetchedMessages,
|
||||
ensureThreadIdForSend,
|
||||
onStreamingComplete,
|
||||
);
|
||||
|
||||
const setAgentChatMessages = useSetAtomComponentFamilyState(
|
||||
agentChatMessagesComponentFamilyState,
|
||||
{ threadId: currentAIChatThread },
|
||||
);
|
||||
|
||||
const agentChatDisplayedThread = useAtomStateValue(
|
||||
agentChatDisplayedThreadState,
|
||||
);
|
||||
|
||||
const setAgentChatDisplayedThread = useSetAtomState(
|
||||
agentChatDisplayedThreadState,
|
||||
);
|
||||
|
||||
const setAgentChatIsInitialScrollPendingOnThreadChange = useSetAtomState(
|
||||
agentChatIsInitialScrollPendingOnThreadChangeState,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mergedMessages = mergeAgentChatFetchedAndStreamingMessages(
|
||||
agentChatFetchedMessages,
|
||||
chatState.messages,
|
||||
);
|
||||
setAgentChatMessages(mergedMessages);
|
||||
|
||||
if (currentAIChatThread !== agentChatDisplayedThread) {
|
||||
if (mergedMessages.length > 0) {
|
||||
setAgentChatIsInitialScrollPendingOnThreadChange(true);
|
||||
}
|
||||
setAgentChatDisplayedThread(currentAIChatThread);
|
||||
}
|
||||
}, [
|
||||
agentChatFetchedMessages,
|
||||
chatState.messages,
|
||||
chatState.status,
|
||||
setAgentChatMessages,
|
||||
currentAIChatThread,
|
||||
agentChatDisplayedThread,
|
||||
setAgentChatDisplayedThread,
|
||||
setAgentChatIsInitialScrollPendingOnThreadChange,
|
||||
]);
|
||||
|
||||
const setAgentChatIsLoading = useSetAtomState(agentChatIsLoadingState);
|
||||
const agentChatThreadsLoading = useAtomStateValue(
|
||||
agentChatThreadsLoadingState,
|
||||
);
|
||||
const agentChatMessagesLoading = useAtomStateValue(
|
||||
agentChatMessagesLoadingState,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const combinedIsLoading =
|
||||
chatState.isLoading ||
|
||||
agentChatMessagesLoading ||
|
||||
agentChatThreadsLoading;
|
||||
|
||||
setAgentChatIsLoading(combinedIsLoading);
|
||||
}, [
|
||||
chatState.isLoading,
|
||||
agentChatMessagesLoading,
|
||||
agentChatThreadsLoading,
|
||||
setAgentChatIsLoading,
|
||||
]);
|
||||
|
||||
const setAgentChatError = useSetAtomState(agentChatErrorState);
|
||||
|
||||
useEffect(() => {
|
||||
setAgentChatError(chatState.error);
|
||||
}, [chatState.error, setAgentChatError]);
|
||||
|
||||
const setAgentChatIsStreaming = useSetAtomState(agentChatIsStreamingState);
|
||||
|
||||
useEffect(() => {
|
||||
setAgentChatIsStreaming(chatState.status === 'streaming');
|
||||
}, [chatState.status, setAgentChatIsStreaming]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
import { useAgentChat } from '@/ai/hooks/useAgentChat';
|
||||
import { useAgentChatData } from '@/ai/hooks/useAgentChatData';
|
||||
import { useAgentChatScrollToBottom } from '@/ai/hooks/useAgentChatScrollToBottom';
|
||||
import { useProcessIncrementalStreamMessages } from '@/ai/hooks/useProcessIncrementalStreamMessages';
|
||||
import { agentChatErrorState } from '@/ai/states/agentChatErrorState';
|
||||
import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState';
|
||||
import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState';
|
||||
import { agentChatMessagesComponentState } from '@/ai/states/agentChatMessagesComponentState';
|
||||
import { agentChatUISessionStartTimeState } from '@/ai/states/agentChatUISessionStartTimeState';
|
||||
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useEffect } from 'react';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const AgentChatDataEffect = () => {
|
||||
const { uiMessages, isLoading, ensureThreadIdForSend } = useAgentChatData();
|
||||
const chatState = useAgentChat(uiMessages, ensureThreadIdForSend);
|
||||
|
||||
const combinedIsLoading = chatState.isLoading || isLoading;
|
||||
const isStreaming = chatState.status === 'streaming';
|
||||
|
||||
const setAgentChatIsLoading = useSetAtomState(agentChatIsLoadingState);
|
||||
const setAgentChatError = useSetAtomState(agentChatErrorState);
|
||||
|
||||
const [agentChatUISessionStartTime, setAgentChatUISessionStartTime] =
|
||||
useAtomState(agentChatUISessionStartTimeState);
|
||||
|
||||
const [, setAgentChatMessages] = useAtomComponentState(
|
||||
agentChatMessagesComponentState,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setAgentChatMessages(chatState.messages);
|
||||
}, [chatState.messages, setAgentChatMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
setAgentChatIsLoading(combinedIsLoading);
|
||||
}, [combinedIsLoading, setAgentChatIsLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
setAgentChatError(chatState.error);
|
||||
}, [chatState.error, setAgentChatError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (agentChatUISessionStartTime === null) {
|
||||
setAgentChatUISessionStartTime(Temporal.Now.instant());
|
||||
}
|
||||
}, [agentChatUISessionStartTime, setAgentChatUISessionStartTime]);
|
||||
|
||||
const setAgentChatIsStreaming = useSetAtomState(agentChatIsStreamingState);
|
||||
|
||||
useEffect(() => {
|
||||
setAgentChatIsStreaming(isStreaming);
|
||||
}, [setAgentChatIsStreaming, isStreaming]);
|
||||
|
||||
const { scrollToBottom, isNearBottom } = useAgentChatScrollToBottom();
|
||||
|
||||
const { processIncrementalStreamMessages } =
|
||||
useProcessIncrementalStreamMessages();
|
||||
|
||||
useEffect(() => {
|
||||
if (chatState.messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNearBottom) {
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
processIncrementalStreamMessages(chatState.messages);
|
||||
}, [
|
||||
chatState.messages,
|
||||
scrollToBottom,
|
||||
isNearBottom,
|
||||
processIncrementalStreamMessages,
|
||||
]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME } from '@/ai/constants/AgentChatRefetchMessagesEventName';
|
||||
import { AGENT_CHAT_NEW_THREAD_DRAFT_KEY } from '@/ai/states/agentChatDraftsByThreadIdState';
|
||||
import { agentChatFetchedMessagesComponentFamilyState } from '@/ai/states/agentChatFetchedMessagesComponentFamilyState';
|
||||
import { agentChatMessagesLoadingState } from '@/ai/states/agentChatMessagesLoadingState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { skipMessagesSkeletonUntilLoadedState } from '@/ai/states/skipMessagesSkeletonUntilLoadedState';
|
||||
import { mapDBMessagesToUIMessages } from '@/ai/utils/mapDBMessagesToUIMessages';
|
||||
import { useQueryWithCallbacks } from '@/apollo/hooks/useQueryWithCallbacks';
|
||||
import { useListenToBrowserEvent } from '@/browser-event/hooks/useListenToBrowserEvent';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomComponentFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentFamilyState';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import {
|
||||
GetChatMessagesDocument,
|
||||
type GetChatMessagesQuery,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
export const AgentChatMessagesFetchEffect = () => {
|
||||
const currentAIChatThread = useAtomStateValue(currentAIChatThreadState);
|
||||
|
||||
const isNewThread = useMemo(
|
||||
() => currentAIChatThread === AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
|
||||
[currentAIChatThread],
|
||||
);
|
||||
|
||||
const setAgentChatMessagesLoading = useSetAtomState(
|
||||
agentChatMessagesLoadingState,
|
||||
);
|
||||
|
||||
const setSkipMessagesSkeletonUntilLoaded = useSetAtomState(
|
||||
skipMessagesSkeletonUntilLoadedState,
|
||||
);
|
||||
|
||||
const setAgentChatFetchedMessages = useSetAtomComponentFamilyState(
|
||||
agentChatFetchedMessagesComponentFamilyState,
|
||||
{ threadId: currentAIChatThread },
|
||||
);
|
||||
|
||||
const handleFirstLoad = useCallback(
|
||||
(_data: GetChatMessagesQuery) => {
|
||||
setSkipMessagesSkeletonUntilLoaded(false);
|
||||
},
|
||||
[setSkipMessagesSkeletonUntilLoaded],
|
||||
);
|
||||
|
||||
const handleDataLoaded = useCallback(
|
||||
(data: GetChatMessagesQuery) => {
|
||||
const uiMessages = mapDBMessagesToUIMessages(data.chatMessages ?? []);
|
||||
setAgentChatFetchedMessages(uiMessages);
|
||||
},
|
||||
[setAgentChatFetchedMessages],
|
||||
);
|
||||
|
||||
const handleLoadingChange = useCallback(
|
||||
(loading: boolean) => {
|
||||
setAgentChatMessagesLoading(loading);
|
||||
},
|
||||
[setAgentChatMessagesLoading],
|
||||
);
|
||||
|
||||
const { refetch: refetchAgentChatMessages } = useQueryWithCallbacks(
|
||||
GetChatMessagesDocument,
|
||||
{
|
||||
variables: { threadId: currentAIChatThread },
|
||||
skip: !isDefined(currentAIChatThread) || isNewThread,
|
||||
onFirstLoad: handleFirstLoad,
|
||||
onDataLoaded: handleDataLoaded,
|
||||
onLoadingChange: handleLoadingChange,
|
||||
},
|
||||
);
|
||||
|
||||
const handleRefetchMessages = useCallback(() => {
|
||||
refetchAgentChatMessages();
|
||||
}, [refetchAgentChatMessages]);
|
||||
|
||||
useListenToBrowserEvent({
|
||||
eventName: AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME,
|
||||
onBrowserEvent: handleRefetchMessages,
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
import { AgentChatDataEffect } from '@/ai/components/AgentChatDataEffect';
|
||||
import { AgentChatContext } from '@/ai/contexts/AgentChatContext';
|
||||
import { useAgentChatData } from '@/ai/hooks/useAgentChatData';
|
||||
import { AgentChatAiSdkStreamEffect } from '@/ai/components/AgentChatAiSdkStreamEffect';
|
||||
import { AgentChatMessagesFetchEffect } from '@/ai/components/AgentChatMessagesFetchEffect';
|
||||
import { AgentChatSessionStartTimeEffect } from '@/ai/components/AgentChatSessionStartTimeEffect';
|
||||
|
||||
import { AgentChatStreamingAutoScrollEffect } from '@/ai/components/AgentChatStreamingAutoScrollEffect';
|
||||
import { AgentChatStreamingPartsDiffSyncEffect } from '@/ai/components/AgentChatStreamingPartsDiffSyncEffect';
|
||||
import { AgentChatThreadInitializationEffect } from '@/ai/components/AgentChatThreadInitializationEffect';
|
||||
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
|
|
@ -9,25 +13,19 @@ export const AgentChatProviderContent = ({
|
|||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const { ensureThreadForDraft, threadsLoading, messagesLoading } =
|
||||
useAgentChatData();
|
||||
|
||||
const contextValue = {
|
||||
ensureThreadForDraft,
|
||||
threadsLoading,
|
||||
messagesLoading,
|
||||
};
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<AgentChatContext.Provider value={contextValue}>
|
||||
<AgentChatComponentInstanceContext.Provider
|
||||
value={{ instanceId: 'agentChatComponentInstance' }}
|
||||
>
|
||||
<AgentChatDataEffect />
|
||||
{children}
|
||||
</AgentChatComponentInstanceContext.Provider>
|
||||
</AgentChatContext.Provider>
|
||||
<AgentChatComponentInstanceContext.Provider
|
||||
value={{ instanceId: 'agentChatComponentInstance' }}
|
||||
>
|
||||
<AgentChatThreadInitializationEffect />
|
||||
<AgentChatMessagesFetchEffect />
|
||||
<AgentChatAiSdkStreamEffect />
|
||||
<AgentChatStreamingPartsDiffSyncEffect />
|
||||
<AgentChatSessionStartTimeEffect />
|
||||
<AgentChatStreamingAutoScrollEffect />
|
||||
{children}
|
||||
</AgentChatComponentInstanceContext.Provider>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId';
|
||||
import { agentChatIsInitialScrollPendingOnThreadChangeState } from '@/ai/states/agentChatIsInitialScrollPendingOnThreadChangeState';
|
||||
import { scrollAIChatToBottom } from '@/ai/utils/scrollAIChatToBottom';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const SCROLL_SETTLE_DELAY_MS = 150;
|
||||
|
||||
export const AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect =
|
||||
() => {
|
||||
const agentChatIsInitialScrollPendingOnThreadChange = useAtomStateValue(
|
||||
agentChatIsInitialScrollPendingOnThreadChangeState,
|
||||
);
|
||||
|
||||
const setAgentChatIsInitialScrollPendingOnThreadChange = useSetAtomState(
|
||||
agentChatIsInitialScrollPendingOnThreadChangeState,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentChatIsInitialScrollPendingOnThreadChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollWrapperElement = document.getElementById(
|
||||
`scroll-wrapper-${AI_CHAT_SCROLL_WRAPPER_ID}`,
|
||||
);
|
||||
|
||||
if (!scrollWrapperElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
let settleTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const scheduleSettle = () => {
|
||||
if (settleTimeoutId !== null) {
|
||||
clearTimeout(settleTimeoutId);
|
||||
}
|
||||
|
||||
scrollAIChatToBottom();
|
||||
|
||||
settleTimeoutId = setTimeout(() => {
|
||||
scrollAIChatToBottom();
|
||||
setAgentChatIsInitialScrollPendingOnThreadChange(false);
|
||||
mutationObserver.disconnect();
|
||||
}, SCROLL_SETTLE_DELAY_MS);
|
||||
};
|
||||
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
scheduleSettle();
|
||||
});
|
||||
|
||||
mutationObserver.observe(scrollWrapperElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
});
|
||||
|
||||
scheduleSettle();
|
||||
|
||||
return () => {
|
||||
if (settleTimeoutId !== null) {
|
||||
clearTimeout(settleTimeoutId);
|
||||
}
|
||||
mutationObserver.disconnect();
|
||||
};
|
||||
}, [
|
||||
agentChatIsInitialScrollPendingOnThreadChange,
|
||||
setAgentChatIsInitialScrollPendingOnThreadChange,
|
||||
]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { agentChatUISessionStartTimeState } from '@/ai/states/agentChatUISessionStartTimeState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useEffect } from 'react';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const AgentChatSessionStartTimeEffect = () => {
|
||||
const [agentChatUISessionStartTime, setAgentChatUISessionStartTime] =
|
||||
useAtomState(agentChatUISessionStartTimeState);
|
||||
|
||||
useEffect(() => {
|
||||
if (agentChatUISessionStartTime === null) {
|
||||
setAgentChatUISessionStartTime(Temporal.Now.instant());
|
||||
}
|
||||
}, [agentChatUISessionStartTime, setAgentChatUISessionStartTime]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { agentChatIsScrolledToBottomSelector } from '@/ai/states/agentChatIsScrolledToBottomSelector';
|
||||
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { scrollAIChatToBottom } from '@/ai/utils/scrollAIChatToBottom';
|
||||
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const AgentChatStreamingAutoScrollEffect = () => {
|
||||
const currentAIChatThread = useAtomStateValue(currentAIChatThreadState);
|
||||
|
||||
const agentChatMessages = useAtomComponentFamilyStateValue(
|
||||
agentChatMessagesComponentFamilyState,
|
||||
{ threadId: currentAIChatThread },
|
||||
);
|
||||
|
||||
const agentChatIsScrolledToBottom = useAtomStateValue(
|
||||
agentChatIsScrolledToBottomSelector,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (agentChatMessages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (agentChatIsScrolledToBottom) {
|
||||
scrollAIChatToBottom();
|
||||
}
|
||||
}, [agentChatMessages, agentChatIsScrolledToBottom]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { useUpdateStreamingPartsWithDiff } from '@/ai/hooks/useUpdateStreamingPartsWithDiff';
|
||||
import { agentChatLastDiffSyncedThreadState } from '@/ai/states/agentChatLastDiffSyncedThreadState';
|
||||
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const AgentChatStreamingPartsDiffSyncEffect = () => {
|
||||
const currentAIChatThread = useAtomStateValue(currentAIChatThreadState);
|
||||
|
||||
const agentChatMessages = useAtomComponentFamilyStateValue(
|
||||
agentChatMessagesComponentFamilyState,
|
||||
{ threadId: currentAIChatThread },
|
||||
);
|
||||
|
||||
const { updateStreamingPartsWithDiff } = useUpdateStreamingPartsWithDiff();
|
||||
|
||||
const setAgentChatLastDiffSyncedThread = useSetAtomState(
|
||||
agentChatLastDiffSyncedThreadState,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (agentChatMessages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateStreamingPartsWithDiff(agentChatMessages);
|
||||
setAgentChatLastDiffSyncedThread(currentAIChatThread);
|
||||
}, [
|
||||
agentChatMessages,
|
||||
updateStreamingPartsWithDiff,
|
||||
currentAIChatThread,
|
||||
setAgentChatLastDiffSyncedThread,
|
||||
]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import { useStore } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { CHAT_THREADS_PAGE_SIZE } from '@/ai/constants/ChatThreads';
|
||||
import {
|
||||
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
|
||||
agentChatDraftsByThreadIdState,
|
||||
} from '@/ai/states/agentChatDraftsByThreadIdState';
|
||||
import { agentChatInputState } from '@/ai/states/agentChatInputState';
|
||||
import { agentChatThreadsLoadingState } from '@/ai/states/agentChatThreadsLoadingState';
|
||||
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState';
|
||||
import { hasTriggeredCreateForDraftState } from '@/ai/states/hasTriggeredCreateForDraftState';
|
||||
import { useQueryWithCallbacks } from '@/apollo/hooks/useQueryWithCallbacks';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import {
|
||||
type GetChatThreadsQuery,
|
||||
GetChatThreadsDocument,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
export const AgentChatThreadInitializationEffect = () => {
|
||||
const currentAIChatThread = useAtomStateValue(currentAIChatThreadState);
|
||||
const setCurrentAIChatThread = useSetAtomState(currentAIChatThreadState);
|
||||
const setAgentChatInput = useSetAtomState(agentChatInputState);
|
||||
const setAgentChatUsage = useSetAtomState(agentChatUsageState);
|
||||
const setCurrentAIChatThreadTitle = useSetAtomState(
|
||||
currentAIChatThreadTitleState,
|
||||
);
|
||||
const setAgentChatThreadsLoading = useSetAtomState(
|
||||
agentChatThreadsLoadingState,
|
||||
);
|
||||
const store = useStore();
|
||||
|
||||
const handleFirstLoad = useCallback(
|
||||
(data: GetChatThreadsQuery) => {
|
||||
const threads = data.chatThreads.edges.map((edge) => edge.node);
|
||||
|
||||
if (threads.length > 0) {
|
||||
const firstThread = threads[0];
|
||||
const draftForThread =
|
||||
store.get(agentChatDraftsByThreadIdState.atom)[firstThread.id] ?? '';
|
||||
|
||||
setCurrentAIChatThread(firstThread.id);
|
||||
setAgentChatInput(draftForThread);
|
||||
setCurrentAIChatThreadTitle(firstThread.title ?? null);
|
||||
|
||||
const hasUsageData =
|
||||
(firstThread.conversationSize ?? 0) > 0 &&
|
||||
isDefined(firstThread.contextWindowTokens);
|
||||
|
||||
setAgentChatUsage(
|
||||
hasUsageData
|
||||
? {
|
||||
lastMessage: null,
|
||||
conversationSize: firstThread.conversationSize ?? 0,
|
||||
contextWindowTokens: firstThread.contextWindowTokens ?? 0,
|
||||
inputTokens: firstThread.totalInputTokens,
|
||||
outputTokens: firstThread.totalOutputTokens,
|
||||
inputCredits: firstThread.totalInputCredits,
|
||||
outputCredits: firstThread.totalOutputCredits,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
} else {
|
||||
store.set(hasTriggeredCreateForDraftState.atom, false);
|
||||
setCurrentAIChatThread(AGENT_CHAT_NEW_THREAD_DRAFT_KEY);
|
||||
setAgentChatInput(
|
||||
store.get(agentChatDraftsByThreadIdState.atom)[
|
||||
AGENT_CHAT_NEW_THREAD_DRAFT_KEY
|
||||
] ?? '',
|
||||
);
|
||||
setCurrentAIChatThreadTitle(null);
|
||||
setAgentChatUsage(null);
|
||||
}
|
||||
},
|
||||
[
|
||||
setCurrentAIChatThread,
|
||||
setAgentChatInput,
|
||||
setCurrentAIChatThreadTitle,
|
||||
setAgentChatUsage,
|
||||
store,
|
||||
],
|
||||
);
|
||||
|
||||
const handleLoadingChange = useCallback(
|
||||
(loading: boolean) => {
|
||||
setAgentChatThreadsLoading(loading);
|
||||
},
|
||||
[setAgentChatThreadsLoading],
|
||||
);
|
||||
|
||||
useQueryWithCallbacks(GetChatThreadsDocument, {
|
||||
variables: { paging: { first: CHAT_THREADS_PAGE_SIZE } },
|
||||
skip: isDefined(currentAIChatThread),
|
||||
onFirstLoad: handleFirstLoad,
|
||||
onLoadingChange: handleLoadingChange,
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
@ -11,8 +11,11 @@ import { ComponentDecorator } from 'twenty-ui/testing';
|
|||
import { AIChatMessage } from '@/ai/components/AIChatMessage';
|
||||
|
||||
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
|
||||
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
|
||||
import { agentChatMessageComponentFamilyState } from '@/ai/states/agentChatMessageComponentFamilyState';
|
||||
import { agentChatMessagesComponentState } from '@/ai/states/agentChatMessagesComponentState';
|
||||
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import { useStore } from 'jotai';
|
||||
import { RootDecorator } from '~/testing/decorators/RootDecorator';
|
||||
|
|
@ -255,8 +258,15 @@ const AgentChatMessagesSetterEffect = ({
|
|||
const store = useStore();
|
||||
|
||||
useEffect(() => {
|
||||
const currentThreadId = store.get(currentAIChatThreadState.atom);
|
||||
|
||||
store.set(agentChatDisplayedThreadState.atom, currentThreadId);
|
||||
|
||||
store.set(
|
||||
agentChatMessagesComponentState.atomFamily({ instanceId: INSTANCE_ID }),
|
||||
agentChatMessagesComponentFamilyState.atomFamily({
|
||||
instanceId: INSTANCE_ID,
|
||||
familyKey: { threadId: currentThreadId },
|
||||
}),
|
||||
messages,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { styled } from '@linaria/react';
|
||||
import { useContext } from 'react';
|
||||
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { useAgentChatContext } from '@/ai/contexts/AgentChatContext';
|
||||
import { AGENT_CHAT_NEW_THREAD_DRAFT_KEY } from '@/ai/states/agentChatDraftsByThreadIdState';
|
||||
import { agentChatMessagesLoadingState } from '@/ai/states/agentChatMessagesLoadingState';
|
||||
import { agentChatThreadsLoadingState } from '@/ai/states/agentChatThreadsLoadingState';
|
||||
import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { skipMessagesSkeletonUntilLoadedState } from '@/ai/states/skipMessagesSkeletonUntilLoadedState';
|
||||
|
|
@ -34,7 +34,12 @@ const NUMBER_OF_SKELETONS = 6;
|
|||
|
||||
export const AIChatSkeletonLoader = () => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { threadsLoading, messagesLoading } = useAgentChatContext();
|
||||
const agentChatThreadsLoading = useAtomStateValue(
|
||||
agentChatThreadsLoadingState,
|
||||
);
|
||||
const agentChatMessagesLoading = useAtomStateValue(
|
||||
agentChatMessagesLoadingState,
|
||||
);
|
||||
const skipMessagesSkeletonUntilLoaded = useAtomStateValue(
|
||||
skipMessagesSkeletonUntilLoadedState,
|
||||
);
|
||||
|
|
@ -45,13 +50,12 @@ export const AIChatSkeletonLoader = () => {
|
|||
);
|
||||
|
||||
const isOnNewChatSlot =
|
||||
!isDefined(currentAIChatThread) ||
|
||||
currentAIChatThread === AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
|
||||
const showForMessagesLoading =
|
||||
messagesLoading && !skipMessagesSkeletonUntilLoaded;
|
||||
agentChatMessagesLoading && !skipMessagesSkeletonUntilLoaded;
|
||||
const shouldRender =
|
||||
!hasMessages &&
|
||||
((threadsLoading && isOnNewChatSlot) || showForMessagesLoading);
|
||||
((agentChatThreadsLoading && isOnNewChatSlot) || showForMessagesLoading);
|
||||
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
export const AGENT_CHAT_ENSURE_THREAD_FOR_DRAFT_EVENT_NAME =
|
||||
'agent-chat-ensure-thread-for-draft' as const;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export const AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME =
|
||||
'agent-chat-refetch-messages' as const;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const AGENT_CHAT_UNKNOWN_THREAD_ID = 'unknown-thread';
|
||||
|
|
@ -8,7 +8,6 @@ import { useEditor } from '@tiptap/react';
|
|||
import { useMemo } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { useAgentChatContext } from '@/ai/contexts/AgentChatContext';
|
||||
import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
|
||||
import {
|
||||
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
|
||||
|
|
@ -16,6 +15,7 @@ import {
|
|||
} from '@/ai/states/agentChatDraftsByThreadIdState';
|
||||
import { agentChatInputState } from '@/ai/states/agentChatInputState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { dispatchAgentChatEnsureThreadForDraftEvent } from '@/ai/utils/dispatchAgentChatEnsureThreadForDraftEvent';
|
||||
import { dispatchAgentChatSendMessageEvent } from '@/ai/utils/dispatchAgentChatSendMessageEvent';
|
||||
import { MENTION_SUGGESTION_PLUGIN_KEY } from '@/mention/constants/MentionSuggestionPluginKey';
|
||||
import { MentionSuggestion } from '@/mention/extensions/MentionSuggestion';
|
||||
|
|
@ -44,14 +44,12 @@ export const useAIChatEditor = () => {
|
|||
const currentAIChatThread = useAtomStateValue(currentAIChatThreadState);
|
||||
const [agentChatDraftsByThreadId, setAgentChatDraftsByThreadId] =
|
||||
useAtomState(agentChatDraftsByThreadIdState);
|
||||
const { ensureThreadForDraft } = useAgentChatContext();
|
||||
|
||||
const { searchMentionRecords } = useMentionSearch();
|
||||
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
|
||||
const { removeFocusItemFromFocusStackById } =
|
||||
useRemoveFocusItemFromFocusStackById();
|
||||
|
||||
const draftKey = currentAIChatThread ?? AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
|
||||
const draftKey = currentAIChatThread;
|
||||
const initialDraft = agentChatDraftsByThreadId[draftKey] ?? '';
|
||||
const initialContent = textToTiptapContent(initialDraft);
|
||||
|
||||
|
|
@ -102,7 +100,7 @@ export const useAIChatEditor = () => {
|
|||
setAgentChatInput(text);
|
||||
setAgentChatDraftsByThreadId((prev) => ({ ...prev, [draftKey]: text }));
|
||||
if (draftKey === AGENT_CHAT_NEW_THREAD_DRAFT_KEY && text.trim() !== '') {
|
||||
ensureThreadForDraft?.();
|
||||
dispatchAgentChatEnsureThreadForDraftEvent();
|
||||
}
|
||||
},
|
||||
onFocus: () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
import {
|
||||
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
|
||||
agentChatDraftsByThreadIdState,
|
||||
} from '@/ai/states/agentChatDraftsByThreadIdState';
|
||||
import { agentChatDraftsByThreadIdState } from '@/ai/states/agentChatDraftsByThreadIdState';
|
||||
import { agentChatInputState } from '@/ai/states/agentChatInputState';
|
||||
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
|
|
@ -41,8 +38,7 @@ export const useAIChatThreadClick = (
|
|||
|
||||
const handleThreadClick = (thread: AgentChatThread) => {
|
||||
setThreadIdCreatedFromDraft(null);
|
||||
const previousDraftKey =
|
||||
currentAIChatThread ?? AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
|
||||
const previousDraftKey = currentAIChatThread;
|
||||
const isSameThread = thread.id === currentAIChatThread;
|
||||
|
||||
setAgentChatDraftsByThreadId((prev) => ({
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import { cookieStorage } from '~/utils/cookie-storage';
|
|||
export const useAgentChat = (
|
||||
uiMessages: ExtendedUIMessage[],
|
||||
ensureThreadIdForSend: () => Promise<string | null>,
|
||||
onStreamingComplete?: () => void,
|
||||
) => {
|
||||
const setTokenPair = useSetAtomState(tokenPairState);
|
||||
const setAgentChatUsage = useSetAtomState(agentChatUsageState);
|
||||
|
|
@ -206,6 +207,8 @@ export const useAgentChat = (
|
|||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
onStreamingComplete?.();
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,309 +0,0 @@
|
|||
import { useApolloClient, useMutation, useQuery } from '@apollo/client/react';
|
||||
import { getOperationName } from '~/utils/getOperationName';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useStore } from 'jotai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { CHAT_THREADS_PAGE_SIZE } from '@/ai/constants/ChatThreads';
|
||||
import { useAgentChatScrollToBottom } from '@/ai/hooks/useAgentChatScrollToBottom';
|
||||
import {
|
||||
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
|
||||
agentChatDraftsByThreadIdState,
|
||||
} from '@/ai/states/agentChatDraftsByThreadIdState';
|
||||
import { agentChatInputState } from '@/ai/states/agentChatInputState';
|
||||
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState';
|
||||
import { focusEditorAfterMigrateState } from '@/ai/states/focusEditorAfterMigrateState';
|
||||
import { hasTriggeredCreateForDraftState } from '@/ai/states/hasTriggeredCreateForDraftState';
|
||||
import { isCreatingChatThreadState } from '@/ai/states/isCreatingChatThreadState';
|
||||
import { isCreatingForFirstSendState } from '@/ai/states/isCreatingForFirstSendState';
|
||||
import { pendingCreateFromDraftPromiseState } from '@/ai/states/pendingCreateFromDraftPromiseState';
|
||||
import { skipMessagesSkeletonUntilLoadedState } from '@/ai/states/skipMessagesSkeletonUntilLoadedState';
|
||||
import { threadIdCreatedFromDraftState } from '@/ai/states/threadIdCreatedFromDraftState';
|
||||
import { mapDBMessagesToUIMessages } from '@/ai/utils/mapDBMessagesToUIMessages';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
|
||||
import {
|
||||
type GetChatThreadsQuery,
|
||||
GetChatThreadsDocument,
|
||||
CreateChatThreadDocument,
|
||||
GetChatMessagesDocument,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
export const useAgentChatData = () => {
|
||||
const [currentAIChatThread, setCurrentAIChatThread] = useAtomState(
|
||||
currentAIChatThreadState,
|
||||
);
|
||||
const setAgentChatInput = useSetAtomState(agentChatInputState);
|
||||
const setAgentChatUsage = useSetAtomState(agentChatUsageState);
|
||||
const setCurrentAIChatThreadTitle = useSetAtomState(
|
||||
currentAIChatThreadTitleState,
|
||||
);
|
||||
const [, setIsCreatingChatThread] = useAtomState(isCreatingChatThreadState);
|
||||
const setAgentChatDraftsByThreadId = useSetAtomState(
|
||||
agentChatDraftsByThreadIdState,
|
||||
);
|
||||
const setPendingCreateFromDraftPromise = useSetAtomState(
|
||||
pendingCreateFromDraftPromiseState,
|
||||
);
|
||||
const store = useStore();
|
||||
const apolloClient = useApolloClient();
|
||||
|
||||
const { scrollToBottom } = useAgentChatScrollToBottom();
|
||||
|
||||
const [createChatThread] = useMutation(CreateChatThreadDocument, {
|
||||
onCompleted: (data) => {
|
||||
if (store.get(isCreatingForFirstSendState.atom)) {
|
||||
store.set(isCreatingForFirstSendState.atom, false);
|
||||
setIsCreatingChatThread(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const newThreadId = data.createChatThread.id;
|
||||
const previousDraftKey =
|
||||
store.get(currentAIChatThreadState.atom) ??
|
||||
AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
|
||||
const draftsSnapshot = store.get(agentChatDraftsByThreadIdState.atom);
|
||||
const newDraft = draftsSnapshot[AGENT_CHAT_NEW_THREAD_DRAFT_KEY] ?? '';
|
||||
|
||||
setIsCreatingChatThread(false);
|
||||
if (previousDraftKey === AGENT_CHAT_NEW_THREAD_DRAFT_KEY) {
|
||||
store.set(hasTriggeredCreateForDraftState.atom, true);
|
||||
setAgentChatDraftsByThreadId((prev) => ({
|
||||
...prev,
|
||||
[newThreadId]: newDraft,
|
||||
[AGENT_CHAT_NEW_THREAD_DRAFT_KEY]: '',
|
||||
}));
|
||||
store.set(focusEditorAfterMigrateState.atom, true);
|
||||
store.set(skipMessagesSkeletonUntilLoadedState.atom, true);
|
||||
store.set(threadIdCreatedFromDraftState.atom, newThreadId);
|
||||
} else {
|
||||
setAgentChatDraftsByThreadId((prev) => ({
|
||||
...prev,
|
||||
[previousDraftKey]: store.get(agentChatInputState.atom),
|
||||
}));
|
||||
}
|
||||
setCurrentAIChatThread(newThreadId);
|
||||
setAgentChatInput(newDraft);
|
||||
setCurrentAIChatThreadTitle(null);
|
||||
setAgentChatUsage(null);
|
||||
|
||||
const newThread = data.createChatThread;
|
||||
const threadListVariables = {
|
||||
paging: { first: CHAT_THREADS_PAGE_SIZE },
|
||||
};
|
||||
const existing = apolloClient.cache.readQuery<GetChatThreadsQuery>({
|
||||
query: GetChatThreadsDocument,
|
||||
variables: threadListVariables,
|
||||
});
|
||||
if (isDefined(existing) && isDefined(existing.chatThreads)) {
|
||||
const newNode = {
|
||||
__typename: 'AgentChatThread' as const,
|
||||
...newThread,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
contextWindowTokens: null,
|
||||
conversationSize: 0,
|
||||
totalInputCredits: 0,
|
||||
totalOutputCredits: 0,
|
||||
};
|
||||
const newEdge = {
|
||||
__typename: 'AgentChatThreadEdge' as const,
|
||||
node: newNode,
|
||||
cursor: newThread.id,
|
||||
};
|
||||
apolloClient.cache.writeQuery({
|
||||
query: GetChatThreadsDocument,
|
||||
variables: threadListVariables,
|
||||
data: {
|
||||
chatThreads: {
|
||||
...existing.chatThreads,
|
||||
edges: [newEdge, ...existing.chatThreads.edges],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
setIsCreatingChatThread(false);
|
||||
store.set(isCreatingForFirstSendState.atom, false);
|
||||
store.set(hasTriggeredCreateForDraftState.atom, false);
|
||||
},
|
||||
refetchQueries: [
|
||||
getOperationName(GetChatThreadsDocument) ?? 'GetChatThreads',
|
||||
],
|
||||
});
|
||||
|
||||
const { loading: threadsLoading, data: threadsData } = useQuery(
|
||||
GetChatThreadsDocument,
|
||||
{
|
||||
variables: { paging: { first: CHAT_THREADS_PAGE_SIZE } },
|
||||
skip: isDefined(currentAIChatThread),
|
||||
},
|
||||
);
|
||||
|
||||
// TODO: Refactor this useEffect to avoid unnecessary re-renders (see PR #18584 review)
|
||||
useEffect(() => {
|
||||
if (!threadsData) return;
|
||||
|
||||
const threads = threadsData.chatThreads.edges.map((edge) => edge.node);
|
||||
|
||||
if (threads.length > 0) {
|
||||
const firstThread = threads[0];
|
||||
const newDraft =
|
||||
store.get(agentChatDraftsByThreadIdState.atom)[firstThread.id] ?? '';
|
||||
|
||||
setCurrentAIChatThread(firstThread.id);
|
||||
setAgentChatInput(newDraft);
|
||||
setCurrentAIChatThreadTitle(firstThread.title ?? null);
|
||||
|
||||
const hasUsageData =
|
||||
(firstThread.conversationSize ?? 0) > 0 &&
|
||||
isDefined(firstThread.contextWindowTokens);
|
||||
setAgentChatUsage(
|
||||
hasUsageData
|
||||
? {
|
||||
lastMessage: null,
|
||||
conversationSize: firstThread.conversationSize ?? 0,
|
||||
contextWindowTokens: firstThread.contextWindowTokens ?? 0,
|
||||
inputTokens: firstThread.totalInputTokens,
|
||||
outputTokens: firstThread.totalOutputTokens,
|
||||
inputCredits: firstThread.totalInputCredits,
|
||||
outputCredits: firstThread.totalOutputCredits,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
} else {
|
||||
store.set(hasTriggeredCreateForDraftState.atom, false);
|
||||
setCurrentAIChatThread(AGENT_CHAT_NEW_THREAD_DRAFT_KEY);
|
||||
setAgentChatInput(
|
||||
store.get(agentChatDraftsByThreadIdState.atom)[
|
||||
AGENT_CHAT_NEW_THREAD_DRAFT_KEY
|
||||
] ?? '',
|
||||
);
|
||||
setCurrentAIChatThreadTitle(null);
|
||||
setAgentChatUsage(null);
|
||||
}
|
||||
}, [
|
||||
threadsData,
|
||||
store,
|
||||
setCurrentAIChatThread,
|
||||
setAgentChatInput,
|
||||
setCurrentAIChatThreadTitle,
|
||||
setAgentChatUsage,
|
||||
]);
|
||||
|
||||
const isNewThread = useMemo(
|
||||
() => currentAIChatThread === AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
|
||||
[currentAIChatThread],
|
||||
);
|
||||
|
||||
const { loading: messagesLoading, data } = useQuery(GetChatMessagesDocument, {
|
||||
variables: { threadId: currentAIChatThread! },
|
||||
skip: !isDefined(currentAIChatThread) || isNewThread,
|
||||
});
|
||||
|
||||
// TODO: Refactor this useEffect to avoid unnecessary re-renders (see PR #18584 review)
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
store.set(skipMessagesSkeletonUntilLoadedState.atom, false);
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [data, store, scrollToBottom]);
|
||||
|
||||
const ensureThreadForDraft = useCallback(() => {
|
||||
const current = store.get(currentAIChatThreadState.atom);
|
||||
if (current !== AGENT_CHAT_NEW_THREAD_DRAFT_KEY) {
|
||||
return;
|
||||
}
|
||||
const draft =
|
||||
store.get(agentChatDraftsByThreadIdState.atom)[
|
||||
AGENT_CHAT_NEW_THREAD_DRAFT_KEY
|
||||
] ?? '';
|
||||
if (draft.trim() === '') {
|
||||
return;
|
||||
}
|
||||
if (store.get(hasTriggeredCreateForDraftState.atom)) {
|
||||
return;
|
||||
}
|
||||
if (store.get(isCreatingChatThreadState.atom)) {
|
||||
return;
|
||||
}
|
||||
setIsCreatingChatThread(true);
|
||||
const createPromise = createChatThread();
|
||||
const threadIdPromise = createPromise.then(
|
||||
(result) => result?.data?.createChatThread?.id ?? null,
|
||||
);
|
||||
setPendingCreateFromDraftPromise(threadIdPromise);
|
||||
threadIdPromise.finally(() => {
|
||||
setPendingCreateFromDraftPromise(null);
|
||||
});
|
||||
}, [
|
||||
createChatThread,
|
||||
setPendingCreateFromDraftPromise,
|
||||
store,
|
||||
setIsCreatingChatThread,
|
||||
]);
|
||||
|
||||
const ensureThreadIdForSend = useCallback(async (): Promise<
|
||||
string | null
|
||||
> => {
|
||||
const current = store.get(currentAIChatThreadState.atom);
|
||||
if (current !== AGENT_CHAT_NEW_THREAD_DRAFT_KEY) {
|
||||
return current;
|
||||
}
|
||||
const inFlightCreate = store.get(pendingCreateFromDraftPromiseState.atom);
|
||||
if (
|
||||
store.get(isCreatingChatThreadState.atom) &&
|
||||
isDefined(inFlightCreate)
|
||||
) {
|
||||
try {
|
||||
const threadId = await inFlightCreate;
|
||||
return threadId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
store.set(isCreatingForFirstSendState.atom, true);
|
||||
setIsCreatingChatThread(true);
|
||||
try {
|
||||
const result = await createChatThread();
|
||||
return result?.data?.createChatThread?.id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
setIsCreatingChatThread(false);
|
||||
}
|
||||
}, [createChatThread, store, setIsCreatingChatThread]);
|
||||
|
||||
const threadsLoadingMemoized = useMemo(
|
||||
() => threadsLoading,
|
||||
[threadsLoading],
|
||||
);
|
||||
|
||||
const messagesLoadingMemoized = useMemo(
|
||||
() => messagesLoading,
|
||||
[messagesLoading],
|
||||
);
|
||||
|
||||
const uiMessages = useMemo(
|
||||
() => mapDBMessagesToUIMessages(data?.chatMessages || []),
|
||||
[data?.chatMessages],
|
||||
);
|
||||
|
||||
const isLoading = useMemo(
|
||||
() => messagesLoadingMemoized || threadsLoadingMemoized,
|
||||
[messagesLoadingMemoized, threadsLoadingMemoized],
|
||||
);
|
||||
|
||||
return {
|
||||
uiMessages,
|
||||
isLoading,
|
||||
threadsLoading: threadsLoadingMemoized,
|
||||
messagesLoading: messagesLoadingMemoized,
|
||||
ensureThreadForDraft,
|
||||
ensureThreadIdForSend,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId';
|
||||
import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement';
|
||||
import { scrollWrapperScrollBottomComponentState } from '@/ui/utilities/scroll/states/scrollWrapperScrollBottomComponentState';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const SCROLL_BOTTOM_THRESHOLD_PX = 10;
|
||||
|
||||
export const useAgentChatScrollToBottom = () => {
|
||||
const { getScrollWrapperElement } = useScrollWrapperHTMLElement(
|
||||
AI_CHAT_SCROLL_WRAPPER_ID,
|
||||
);
|
||||
|
||||
const scrollWrapperScrollBottom = useAtomComponentStateValue(
|
||||
scrollWrapperScrollBottomComponentState,
|
||||
AI_CHAT_SCROLL_WRAPPER_ID,
|
||||
);
|
||||
|
||||
const isNearBottom = useMemo(
|
||||
() => scrollWrapperScrollBottom <= SCROLL_BOTTOM_THRESHOLD_PX,
|
||||
[scrollWrapperScrollBottom],
|
||||
);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const { scrollWrapperElement } = getScrollWrapperElement();
|
||||
if (!isDefined(scrollWrapperElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollWrapperElement.scrollTo({
|
||||
top: scrollWrapperElement.scrollHeight,
|
||||
});
|
||||
}, [getScrollWrapperElement]);
|
||||
|
||||
return { scrollToBottom, isNearBottom };
|
||||
};
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { useStore } from 'jotai';
|
||||
|
||||
import {
|
||||
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
|
||||
agentChatDraftsByThreadIdState,
|
||||
} from '@/ai/states/agentChatDraftsByThreadIdState';
|
||||
import { agentChatInputState } from '@/ai/states/agentChatInputState';
|
||||
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState';
|
||||
import { focusEditorAfterMigrateState } from '@/ai/states/focusEditorAfterMigrateState';
|
||||
import { hasTriggeredCreateForDraftState } from '@/ai/states/hasTriggeredCreateForDraftState';
|
||||
import { isCreatingChatThreadState } from '@/ai/states/isCreatingChatThreadState';
|
||||
import { isCreatingForFirstSendState } from '@/ai/states/isCreatingForFirstSendState';
|
||||
import { skipMessagesSkeletonUntilLoadedState } from '@/ai/states/skipMessagesSkeletonUntilLoadedState';
|
||||
import { threadIdCreatedFromDraftState } from '@/ai/states/threadIdCreatedFromDraftState';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import {
|
||||
CreateChatThreadDocument,
|
||||
GetChatThreadsDocument,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { getOperationName } from '~/utils/getOperationName';
|
||||
|
||||
export const useCreateAgentChatThread = () => {
|
||||
const setCurrentAIChatThread = useSetAtomState(currentAIChatThreadState);
|
||||
const setAgentChatInput = useSetAtomState(agentChatInputState);
|
||||
const setAgentChatUsage = useSetAtomState(agentChatUsageState);
|
||||
const setCurrentAIChatThreadTitle = useSetAtomState(
|
||||
currentAIChatThreadTitleState,
|
||||
);
|
||||
const setIsCreatingChatThread = useSetAtomState(isCreatingChatThreadState);
|
||||
const setAgentChatDraftsByThreadId = useSetAtomState(
|
||||
agentChatDraftsByThreadIdState,
|
||||
);
|
||||
const store = useStore();
|
||||
|
||||
const [createChatThread] = useMutation(CreateChatThreadDocument, {
|
||||
onCompleted: (data) => {
|
||||
if (store.get(isCreatingForFirstSendState.atom)) {
|
||||
store.set(isCreatingForFirstSendState.atom, false);
|
||||
setIsCreatingChatThread(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const newThreadId = data.createChatThread.id;
|
||||
const previousDraftKey =
|
||||
store.get(currentAIChatThreadState.atom) ??
|
||||
AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
|
||||
const draftsSnapshot = store.get(agentChatDraftsByThreadIdState.atom);
|
||||
const newDraft = draftsSnapshot[AGENT_CHAT_NEW_THREAD_DRAFT_KEY] ?? '';
|
||||
|
||||
setIsCreatingChatThread(false);
|
||||
|
||||
if (previousDraftKey === AGENT_CHAT_NEW_THREAD_DRAFT_KEY) {
|
||||
store.set(hasTriggeredCreateForDraftState.atom, true);
|
||||
setAgentChatDraftsByThreadId((previousDrafts) => ({
|
||||
...previousDrafts,
|
||||
[newThreadId]: newDraft,
|
||||
[AGENT_CHAT_NEW_THREAD_DRAFT_KEY]: '',
|
||||
}));
|
||||
store.set(focusEditorAfterMigrateState.atom, true);
|
||||
store.set(skipMessagesSkeletonUntilLoadedState.atom, true);
|
||||
store.set(threadIdCreatedFromDraftState.atom, newThreadId);
|
||||
} else {
|
||||
setAgentChatDraftsByThreadId((previousDrafts) => ({
|
||||
...previousDrafts,
|
||||
[previousDraftKey]: store.get(agentChatInputState.atom),
|
||||
}));
|
||||
}
|
||||
|
||||
setCurrentAIChatThread(newThreadId);
|
||||
setAgentChatInput(newDraft);
|
||||
setCurrentAIChatThreadTitle(null);
|
||||
setAgentChatUsage(null);
|
||||
},
|
||||
onError: () => {
|
||||
setIsCreatingChatThread(false);
|
||||
store.set(isCreatingForFirstSendState.atom, false);
|
||||
store.set(hasTriggeredCreateForDraftState.atom, false);
|
||||
},
|
||||
refetchQueries: [
|
||||
getOperationName(GetChatThreadsDocument) ?? 'GetChatThreads',
|
||||
],
|
||||
});
|
||||
|
||||
return { createChatThread };
|
||||
};
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { useStore } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import {
|
||||
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
|
||||
agentChatDraftsByThreadIdState,
|
||||
} from '@/ai/states/agentChatDraftsByThreadIdState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { hasTriggeredCreateForDraftState } from '@/ai/states/hasTriggeredCreateForDraftState';
|
||||
import { isCreatingChatThreadState } from '@/ai/states/isCreatingChatThreadState';
|
||||
import { pendingCreateFromDraftPromiseState } from '@/ai/states/pendingCreateFromDraftPromiseState';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
|
||||
export const useEnsureAgentChatThreadExistsForDraft = (
|
||||
createChatThread: () => Promise<any>,
|
||||
) => {
|
||||
const setIsCreatingChatThread = useSetAtomState(isCreatingChatThreadState);
|
||||
const setPendingCreateFromDraftPromise = useSetAtomState(
|
||||
pendingCreateFromDraftPromiseState,
|
||||
);
|
||||
const store = useStore();
|
||||
|
||||
const ensureThreadExistsForDraft = useCallback(() => {
|
||||
const currentThreadId = store.get(currentAIChatThreadState.atom);
|
||||
|
||||
if (currentThreadId !== AGENT_CHAT_NEW_THREAD_DRAFT_KEY) {
|
||||
return;
|
||||
}
|
||||
|
||||
const draft =
|
||||
store.get(agentChatDraftsByThreadIdState.atom)[
|
||||
AGENT_CHAT_NEW_THREAD_DRAFT_KEY
|
||||
] ?? '';
|
||||
|
||||
if (draft.trim() === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (store.get(hasTriggeredCreateForDraftState.atom)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (store.get(isCreatingChatThreadState.atom)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingChatThread(true);
|
||||
|
||||
const createPromise = createChatThread();
|
||||
const threadIdPromise = createPromise.then(
|
||||
(result) => result?.data?.createChatThread?.id ?? null,
|
||||
);
|
||||
|
||||
setPendingCreateFromDraftPromise(threadIdPromise);
|
||||
|
||||
threadIdPromise.finally(() => {
|
||||
setPendingCreateFromDraftPromise(null);
|
||||
});
|
||||
}, [
|
||||
createChatThread,
|
||||
setPendingCreateFromDraftPromise,
|
||||
store,
|
||||
setIsCreatingChatThread,
|
||||
]);
|
||||
|
||||
return { ensureThreadExistsForDraft };
|
||||
};
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import { useStore } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { AGENT_CHAT_NEW_THREAD_DRAFT_KEY } from '@/ai/states/agentChatDraftsByThreadIdState';
|
||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||
import { isCreatingChatThreadState } from '@/ai/states/isCreatingChatThreadState';
|
||||
import { isCreatingForFirstSendState } from '@/ai/states/isCreatingForFirstSendState';
|
||||
import { pendingCreateFromDraftPromiseState } from '@/ai/states/pendingCreateFromDraftPromiseState';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
|
||||
export const useEnsureAgentChatThreadIdForSend = (
|
||||
createChatThread: () => Promise<any>,
|
||||
) => {
|
||||
const setIsCreatingChatThread = useSetAtomState(isCreatingChatThreadState);
|
||||
const store = useStore();
|
||||
|
||||
const ensureThreadIdForSend = useCallback(async (): Promise<
|
||||
string | null
|
||||
> => {
|
||||
const currentThreadId = store.get(currentAIChatThreadState.atom);
|
||||
|
||||
if (currentThreadId !== AGENT_CHAT_NEW_THREAD_DRAFT_KEY) {
|
||||
return currentThreadId;
|
||||
}
|
||||
|
||||
const inFlightCreatePromise = store.get(
|
||||
pendingCreateFromDraftPromiseState.atom,
|
||||
);
|
||||
|
||||
if (
|
||||
store.get(isCreatingChatThreadState.atom) &&
|
||||
isDefined(inFlightCreatePromise)
|
||||
) {
|
||||
try {
|
||||
const threadId = await inFlightCreatePromise;
|
||||
return threadId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
store.set(isCreatingForFirstSendState.atom, true);
|
||||
setIsCreatingChatThread(true);
|
||||
|
||||
try {
|
||||
const result = await createChatThread();
|
||||
return result?.data?.createChatThread?.id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
setIsCreatingChatThread(false);
|
||||
}
|
||||
}, [createChatThread, store, setIsCreatingChatThread]);
|
||||
|
||||
return { ensureThreadIdForSend };
|
||||
};
|
||||
|
|
@ -6,21 +6,21 @@ import { isNonEmptyString } from '@sniptt/guards';
|
|||
import { Temporal } from 'temporal-polyfill';
|
||||
import { type ExtendedUIMessage } from 'twenty-shared/ai';
|
||||
|
||||
export const useProcessNewMessageStreamIncrement = () => {
|
||||
export const useProcessStreamingMessageUpdate = () => {
|
||||
const agentChatUISessionStartTime = useAtomStateValue(
|
||||
agentChatUISessionStartTimeState,
|
||||
);
|
||||
|
||||
const { processUIToolCallMessage } = useProcessUIToolCallMessage();
|
||||
|
||||
const processNewMessageStreamIncrement = (
|
||||
messageStreamIncrement: ExtendedUIMessage,
|
||||
const processStreamingMessageUpdate = (
|
||||
streamingMessage: ExtendedUIMessage,
|
||||
) => {
|
||||
if (agentChatUISessionStartTime === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const messageCreatedAt = messageStreamIncrement.metadata?.createdAt;
|
||||
const messageCreatedAt = streamingMessage.metadata?.createdAt;
|
||||
|
||||
if (isNonEmptyString(messageCreatedAt)) {
|
||||
const messageCreatedAtInstant = Temporal.Instant.from(messageCreatedAt);
|
||||
|
|
@ -34,14 +34,14 @@ export const useProcessNewMessageStreamIncrement = () => {
|
|||
}
|
||||
}
|
||||
|
||||
const messageIsUIToolCall = isUIToolCallMessage(messageStreamIncrement);
|
||||
const messageIsUIToolCall = isUIToolCallMessage(streamingMessage);
|
||||
|
||||
if (messageIsUIToolCall) {
|
||||
processUIToolCallMessage(messageStreamIncrement);
|
||||
processUIToolCallMessage(streamingMessage);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
processNewMessageStreamIncrement,
|
||||
processStreamingMessageUpdate,
|
||||
};
|
||||
};
|
||||
|
|
@ -14,7 +14,7 @@ import { useOpenAskAIPageInSidePanel } from '@/side-panel/hooks/useOpenAskAIPage
|
|||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
|
||||
export const useCreateNewAIChatThread = () => {
|
||||
export const useSwitchToNewAIChat = () => {
|
||||
const setThreadIdCreatedFromDraft = useSetAtomState(
|
||||
threadIdCreatedFromDraftState,
|
||||
);
|
||||
|
|
@ -34,8 +34,7 @@ export const useCreateNewAIChatThread = () => {
|
|||
|
||||
const switchToNewChat = () => {
|
||||
setThreadIdCreatedFromDraft(null);
|
||||
const previousDraftKey =
|
||||
currentAIChatThread ?? AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
|
||||
const previousDraftKey = currentAIChatThread;
|
||||
const newChatDraft =
|
||||
store.get(agentChatDraftsByThreadIdState.atom)[
|
||||
AGENT_CHAT_NEW_THREAD_DRAFT_KEY
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useProcessNewMessageStreamIncrement } from '@/ai/hooks/useProcessNewMessageStreamIncrement';
|
||||
import { useProcessStreamingMessageUpdate } from '@/ai/hooks/useProcessStreamingMessageUpdate';
|
||||
import { agentChatMessageComponentFamilyState } from '@/ai/states/agentChatMessageComponentFamilyState';
|
||||
import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateCallbackState';
|
||||
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
|
|
@ -7,25 +7,24 @@ import { type ExtendedUIMessage } from 'twenty-shared/ai';
|
|||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
|
||||
|
||||
export const useProcessIncrementalStreamMessages = () => {
|
||||
export const useUpdateStreamingPartsWithDiff = () => {
|
||||
const agentChatMessageFamilyCallbackState =
|
||||
useAtomComponentFamilyStateCallbackState(
|
||||
agentChatMessageComponentFamilyState,
|
||||
);
|
||||
|
||||
const { processNewMessageStreamIncrement } =
|
||||
useProcessNewMessageStreamIncrement();
|
||||
const { processStreamingMessageUpdate } = useProcessStreamingMessageUpdate();
|
||||
|
||||
const processIncrementalStreamMessages = useCallback(
|
||||
(incrementalStreamMessages: ExtendedUIMessage[]) => {
|
||||
for (const updatedMessage of incrementalStreamMessages) {
|
||||
const updateStreamingPartsWithDiff = useCallback(
|
||||
(incomingMessages: ExtendedUIMessage[]) => {
|
||||
for (const incomingMessage of incomingMessages) {
|
||||
const alreadyExistingMessage = jotaiStore.get(
|
||||
agentChatMessageFamilyCallbackState(updatedMessage.id),
|
||||
agentChatMessageFamilyCallbackState(incomingMessage.id),
|
||||
);
|
||||
|
||||
const messageContentHasChanged = !isDeeplyEqual(
|
||||
alreadyExistingMessage,
|
||||
updatedMessage,
|
||||
incomingMessage,
|
||||
);
|
||||
|
||||
const messageAlreadyExists = isDefined(alreadyExistingMessage);
|
||||
|
|
@ -37,20 +36,20 @@ export const useProcessIncrementalStreamMessages = () => {
|
|||
continue;
|
||||
}
|
||||
|
||||
const clonedMessage = structuredClone(updatedMessage);
|
||||
const clonedMessage = structuredClone(incomingMessage);
|
||||
|
||||
jotaiStore.set(
|
||||
agentChatMessageFamilyCallbackState(updatedMessage.id),
|
||||
agentChatMessageFamilyCallbackState(incomingMessage.id),
|
||||
clonedMessage,
|
||||
);
|
||||
|
||||
processNewMessageStreamIncrement(updatedMessage);
|
||||
processStreamingMessageUpdate(incomingMessage);
|
||||
}
|
||||
},
|
||||
[agentChatMessageFamilyCallbackState, processNewMessageStreamIncrement],
|
||||
[agentChatMessageFamilyCallbackState, processStreamingMessageUpdate],
|
||||
);
|
||||
|
||||
return {
|
||||
processIncrementalStreamMessages,
|
||||
updateStreamingPartsWithDiff,
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const agentChatDisplayedThreadState = createAtomState<string>({
|
||||
key: 'ai/agentChatDisplayedThreadState',
|
||||
defaultValue: '',
|
||||
});
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
|
||||
import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilyState';
|
||||
import { type ExtendedUIMessage } from 'twenty-shared/ai';
|
||||
|
||||
export const agentChatFetchedMessagesComponentFamilyState =
|
||||
createAtomComponentFamilyState<ExtendedUIMessage[], { threadId: string }>({
|
||||
key: 'agentChatFetchedMessagesComponentFamilyState',
|
||||
defaultValue: [],
|
||||
componentInstanceContext: AgentChatComponentInstanceContext,
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
|
||||
import { agentChatMessagesComponentState } from '@/ai/states/agentChatMessagesComponentState';
|
||||
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
|
||||
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
|
||||
import { createAtomComponentSelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentSelector';
|
||||
import { isNonEmptyArray } from '@sniptt/guards';
|
||||
|
||||
|
|
@ -10,7 +11,12 @@ export const agentChatHasMessageComponentSelector =
|
|||
get:
|
||||
({ instanceId }) =>
|
||||
({ get }) => {
|
||||
const messages = get(agentChatMessagesComponentState, { instanceId });
|
||||
const currentThreadId = get(agentChatDisplayedThreadState);
|
||||
|
||||
const messages = get(agentChatMessagesComponentFamilyState, {
|
||||
instanceId,
|
||||
familyKey: { threadId: currentThreadId },
|
||||
});
|
||||
|
||||
return isNonEmptyArray(messages);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const agentChatIsInitialScrollPendingOnThreadChangeState =
|
||||
createAtomState<boolean>({
|
||||
key: 'ai/agentChatIsInitialScrollPendingOnThreadChangeState',
|
||||
defaultValue: false,
|
||||
});
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId';
|
||||
import { scrollWrapperScrollBottomComponentState } from '@/ui/utilities/scroll/states/scrollWrapperScrollBottomComponentState';
|
||||
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
|
||||
|
||||
const SCROLL_BOTTOM_THRESHOLD_PX = 100;
|
||||
|
||||
export const agentChatIsScrolledToBottomSelector = createAtomSelector<boolean>({
|
||||
key: 'agentChatIsScrolledToBottomSelector',
|
||||
get: ({ get }) => {
|
||||
const scrollBottom = get(scrollWrapperScrollBottomComponentState, {
|
||||
instanceId: AI_CHAT_SCROLL_WRAPPER_ID,
|
||||
});
|
||||
|
||||
return scrollBottom <= SCROLL_BOTTOM_THRESHOLD_PX;
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const agentChatLastDiffSyncedThreadState = createAtomState<string>({
|
||||
key: 'ai/agentChatLastDiffSyncedThreadState',
|
||||
defaultValue: '',
|
||||
});
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
|
||||
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
|
||||
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
|
||||
import { createAtomComponentSelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentSelector';
|
||||
|
||||
export const agentChatLastMessageIdComponentSelector =
|
||||
createAtomComponentSelector<string | null>({
|
||||
key: 'agentChatLastMessageIdComponentSelector',
|
||||
componentInstanceContext: AgentChatComponentInstanceContext,
|
||||
get:
|
||||
({ instanceId }) =>
|
||||
({ get }) => {
|
||||
const currentThreadId = get(agentChatDisplayedThreadState);
|
||||
|
||||
const messages = get(agentChatMessagesComponentFamilyState, {
|
||||
instanceId,
|
||||
familyKey: { threadId: currentThreadId },
|
||||
});
|
||||
|
||||
return messages.at(-1)?.id ?? null;
|
||||
},
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
|
||||
import { agentChatMessagesComponentState } from '@/ai/states/agentChatMessagesComponentState';
|
||||
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
|
||||
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
|
||||
import { createAtomComponentFamilySelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilySelector';
|
||||
import { type ExtendedUIMessage } from 'twenty-shared/ai';
|
||||
import { type Nullable } from 'twenty-shared/types';
|
||||
|
|
@ -13,7 +14,12 @@ export const agentChatMessageComponentFamilySelector =
|
|||
get:
|
||||
({ instanceId, familyKey: { messageId } }) =>
|
||||
({ get }) => {
|
||||
const messages = get(agentChatMessagesComponentState, { instanceId });
|
||||
const currentThreadId = get(agentChatDisplayedThreadState);
|
||||
|
||||
const messages = get(agentChatMessagesComponentFamilyState, {
|
||||
instanceId,
|
||||
familyKey: { threadId: currentThreadId },
|
||||
});
|
||||
|
||||
return messages.find((message) => message.id === messageId);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
|
||||
import { agentChatMessagesComponentState } from '@/ai/states/agentChatMessagesComponentState';
|
||||
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
|
||||
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
|
||||
import { createAtomComponentSelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentSelector';
|
||||
|
||||
export const agentChatMessageIdsComponentSelector = createAtomComponentSelector<
|
||||
|
|
@ -10,7 +11,12 @@ export const agentChatMessageIdsComponentSelector = createAtomComponentSelector<
|
|||
get:
|
||||
({ instanceId }) =>
|
||||
({ get }) => {
|
||||
const messages = get(agentChatMessagesComponentState, { instanceId });
|
||||
const currentThreadId = get(agentChatDisplayedThreadState);
|
||||
|
||||
const messages = get(agentChatMessagesComponentFamilyState, {
|
||||
instanceId,
|
||||
familyKey: { threadId: currentThreadId },
|
||||
});
|
||||
|
||||
return messages.map((message) => message.id);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
|
||||
import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilyState';
|
||||
import { type ExtendedUIMessage } from 'twenty-shared/ai';
|
||||
|
||||
export const agentChatMessagesComponentFamilyState =
|
||||
createAtomComponentFamilyState<ExtendedUIMessage[], { threadId: string }>({
|
||||
key: 'agentChatMessagesComponentFamilyState',
|
||||
defaultValue: [],
|
||||
componentInstanceContext: AgentChatComponentInstanceContext,
|
||||
});
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const agentChatMessagesLoadingState = createAtomState({
|
||||
key: 'agentChatMessagesLoadingState',
|
||||
defaultValue: false,
|
||||
});
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
|
||||
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
|
||||
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
|
||||
import { createAtomComponentSelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentSelector';
|
||||
|
||||
export const agentChatNonLastMessageIdsComponentSelector =
|
||||
createAtomComponentSelector<string[]>({
|
||||
key: 'agentChatNonLastMessageIdsComponentSelector',
|
||||
componentInstanceContext: AgentChatComponentInstanceContext,
|
||||
get:
|
||||
({ instanceId }) =>
|
||||
({ get }) => {
|
||||
const currentThreadId = get(agentChatDisplayedThreadState);
|
||||
|
||||
const messages = get(agentChatMessagesComponentFamilyState, {
|
||||
instanceId,
|
||||
familyKey: { threadId: currentThreadId },
|
||||
});
|
||||
|
||||
return messages.slice(0, -1).map((message) => message.id);
|
||||
},
|
||||
areEqual: (previous, next) =>
|
||||
previous.length === next.length &&
|
||||
previous.every((id, index) => id === next[index]),
|
||||
});
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const agentChatThreadsLoadingState = createAtomState({
|
||||
key: 'agentChatThreadsLoadingState',
|
||||
defaultValue: false,
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { AGENT_CHAT_UNKNOWN_THREAD_ID } from '@/ai/constants/AgentChatUnknownThreadId';
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const currentAIChatThreadState = createAtomState<string | null>({
|
||||
export const currentAIChatThreadState = createAtomState<string>({
|
||||
key: 'ai/currentAIChatThreadState',
|
||||
defaultValue: null,
|
||||
defaultValue: AGENT_CHAT_UNKNOWN_THREAD_ID,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
import { AGENT_CHAT_ENSURE_THREAD_FOR_DRAFT_EVENT_NAME } from '@/ai/constants/AgentChatEnsureThreadForDraftEventName';
|
||||
|
||||
export const dispatchAgentChatEnsureThreadForDraftEvent = () => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(AGENT_CHAT_ENSURE_THREAD_FOR_DRAFT_EVENT_NAME),
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { type ExtendedUIMessage } from 'twenty-shared/ai';
|
||||
|
||||
export const mergeAgentChatFetchedAndStreamingMessages = (
|
||||
fetchedMessages: ExtendedUIMessage[],
|
||||
streamingMessages: ExtendedUIMessage[],
|
||||
): ExtendedUIMessage[] => {
|
||||
const fetchedMessageIds = new Set(
|
||||
fetchedMessages.map((message) => message.id),
|
||||
);
|
||||
|
||||
const streamingOnlyMessages = streamingMessages.filter(
|
||||
(message) => !fetchedMessageIds.has(message.id),
|
||||
);
|
||||
|
||||
return [...fetchedMessages, ...streamingOnlyMessages];
|
||||
};
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId';
|
||||
|
||||
export const scrollAIChatToBottom = () => {
|
||||
const scrollWrapperElement = document.getElementById(
|
||||
`scroll-wrapper-${AI_CHAT_SCROLL_WRAPPER_ID}`,
|
||||
);
|
||||
|
||||
if (scrollWrapperElement) {
|
||||
scrollWrapperElement.scrollTop = scrollWrapperElement.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import {
|
||||
NetworkStatus,
|
||||
type OperationVariables,
|
||||
type TypedDocumentNode,
|
||||
} from '@apollo/client';
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export type UseQueryWithCallbacksOptions<
|
||||
TData,
|
||||
TVariables extends OperationVariables,
|
||||
> = useQuery.Options<TData, TVariables> & {
|
||||
onFirstLoad?: (data: TData) => void;
|
||||
onSubsequentLoad?: (data: TData) => void;
|
||||
onDataLoaded?: (data: TData) => void;
|
||||
onLoadingChange?: (loading: boolean) => void;
|
||||
};
|
||||
|
||||
export const useQueryWithCallbacks = <
|
||||
TData,
|
||||
TVariables extends OperationVariables,
|
||||
>(
|
||||
document: TypedDocumentNode<TData, TVariables>,
|
||||
options: UseQueryWithCallbacksOptions<TData, TVariables>,
|
||||
) => {
|
||||
const {
|
||||
onFirstLoad,
|
||||
onSubsequentLoad,
|
||||
onDataLoaded,
|
||||
onLoadingChange,
|
||||
...queryOptions
|
||||
} = options;
|
||||
|
||||
const { networkStatus, data, loading, refetch } = useQuery(document, {
|
||||
...queryOptions,
|
||||
notifyOnNetworkStatusChange: true,
|
||||
} as useQuery.Options<TData, TVariables>);
|
||||
|
||||
const variablesString = JSON.stringify(queryOptions.variables);
|
||||
|
||||
const [lastProcessedVariablesString, setLastProcessedVariablesString] =
|
||||
useState<string | null>(null);
|
||||
|
||||
const [hasProcessedCurrentFetchCycle, setHasProcessedCurrentFetchCycle] =
|
||||
useState(false);
|
||||
|
||||
const [hasEverLoaded, setHasEverLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (networkStatus !== NetworkStatus.ready) {
|
||||
setHasProcessedCurrentFetchCycle(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDefined(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const variablesChanged = variablesString !== lastProcessedVariablesString;
|
||||
|
||||
if (hasProcessedCurrentFetchCycle && !variablesChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHasProcessedCurrentFetchCycle(true);
|
||||
setLastProcessedVariablesString(variablesString);
|
||||
|
||||
const isFirstLoad = !hasEverLoaded;
|
||||
|
||||
setHasEverLoaded(true);
|
||||
|
||||
const typedData = data as TData;
|
||||
|
||||
onDataLoaded?.(typedData);
|
||||
|
||||
if (isFirstLoad) {
|
||||
onFirstLoad?.(typedData);
|
||||
} else {
|
||||
onSubsequentLoad?.(typedData);
|
||||
}
|
||||
}, [
|
||||
networkStatus,
|
||||
data,
|
||||
variablesString,
|
||||
lastProcessedVariablesString,
|
||||
hasProcessedCurrentFetchCycle,
|
||||
hasEverLoaded,
|
||||
onFirstLoad,
|
||||
onSubsequentLoad,
|
||||
onDataLoaded,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
onLoadingChange?.(loading);
|
||||
}, [loading, onLoadingChange]);
|
||||
|
||||
return { refetch };
|
||||
};
|
||||
|
|
@ -10,7 +10,7 @@ import { useIsMobile } from 'twenty-ui/utilities';
|
|||
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
|
||||
import { useSwitchToNewAIChat } from '@/ai/hooks/useSwitchToNewAIChat';
|
||||
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
|
||||
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
|
||||
import { navigationDrawerActiveTabState } from '@/ui/navigation/states/navigationDrawerActiveTabState';
|
||||
|
|
@ -127,7 +127,7 @@ export const MainNavigationDrawerTabsRow = () => {
|
|||
);
|
||||
const [navigationDrawerActiveTab, setNavigationDrawerActiveTab] =
|
||||
useAtomState(navigationDrawerActiveTabState);
|
||||
const { switchToNewChat } = useCreateNewAIChatThread();
|
||||
const { switchToNewChat } = useSwitchToNewAIChat();
|
||||
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
|
||||
const setIsNavigationDrawerExpanded = useSetAtomState(
|
||||
isNavigationDrawerExpandedState,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
|
||||
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
|
||||
import { useOpenRecordsSearchPageInSidePanel } from '@/side-panel/hooks/useOpenRecordsSearchPageInSidePanel';
|
||||
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
|
||||
import { useSwitchToNewAIChat } from '@/ai/hooks/useSwitchToNewAIChat';
|
||||
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
|
||||
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
|
||||
import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath';
|
||||
import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage';
|
||||
import { currentMobileNavigationDrawerState } from '@/navigation/states/currentMobileNavigationDrawerState';
|
||||
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
|
||||
import { useOpenRecordsSearchPageInSidePanel } from '@/side-panel/hooks/useOpenRecordsSearchPageInSidePanel';
|
||||
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
|
||||
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
|
||||
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
|
||||
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
|
||||
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
|
|
@ -36,12 +36,12 @@ export const MobileNavigationBar = () => {
|
|||
useAtomState(isNavigationDrawerExpandedState);
|
||||
const [currentMobileNavigationDrawer, setCurrentMobileNavigationDrawer] =
|
||||
useAtomState(currentMobileNavigationDrawerState);
|
||||
const { switchToNewChat } = useCreateNewAIChatThread();
|
||||
const { switchToNewChat } = useSwitchToNewAIChat();
|
||||
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
|
||||
const { alphaSortedActiveNonSystemObjectMetadataItems } =
|
||||
useFilteredObjectMetadataItems();
|
||||
|
||||
const [, setContextStoreCurrentObjectMetadataItemId] = useAtomComponentState(
|
||||
const setContextStoreCurrentObjectMetadataItemId = useSetAtomComponentState(
|
||||
contextStoreCurrentObjectMetadataItemIdComponentState,
|
||||
MAIN_CONTEXT_STORE_INSTANCE_ID,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { IconButton } from 'twenty-ui/input';
|
|||
import { useIsMobile } from 'twenty-ui/utilities';
|
||||
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
||||
|
||||
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
|
||||
import { useSwitchToNewAIChat } from '@/ai/hooks/useSwitchToNewAIChat';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledIconButtonContainer = styled.div`
|
||||
|
|
@ -22,7 +22,7 @@ export const SidePanelTopBarRightCornerIcon = () => {
|
|||
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
|
||||
const sidePanelPage = useAtomStateValue(sidePanelPageState);
|
||||
const { openAskAIPage } = useOpenAskAIPageInSidePanel();
|
||||
const { switchToNewChat } = useCreateNewAIChatThread();
|
||||
const { switchToNewChat } = useSwitchToNewAIChat();
|
||||
|
||||
if (isMobile || !isAiEnabled) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { atom, type Atom } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
|
||||
import { type ComponentInstanceStateContext } from '@/ui/utilities/state/component-state/types/ComponentInstanceStateContext';
|
||||
import { globalComponentInstanceContextMap } from '@/ui/utilities/state/component-state/utils/globalComponentInstanceContextMap';
|
||||
|
|
@ -12,12 +13,14 @@ export const createAtomComponentFamilySelector = <ValueType, FamilyKey>({
|
|||
key,
|
||||
get,
|
||||
componentInstanceContext,
|
||||
areEqual,
|
||||
}: {
|
||||
key: string;
|
||||
get: (
|
||||
key: ComponentFamilyStateKey<FamilyKey>,
|
||||
) => (callbacks: SelectorGetter) => ValueType;
|
||||
componentInstanceContext: ComponentInstanceStateContext<any> | null;
|
||||
areEqual?: (previous: ValueType, next: ValueType) => boolean;
|
||||
}): ComponentFamilySelector<ValueType, FamilyKey> => {
|
||||
if (isDefined(componentInstanceContext)) {
|
||||
globalComponentInstanceContextMap.set(key, componentInstanceContext);
|
||||
|
|
@ -47,10 +50,14 @@ export const createAtomComponentFamilySelector = <ValueType, FamilyKey>({
|
|||
return getForKey({ get: getHelper });
|
||||
});
|
||||
|
||||
derivedAtom.debugLabel = `${key}__${cacheKey}`;
|
||||
atomCache.set(cacheKey, derivedAtom);
|
||||
const finalAtom = isDefined(areEqual)
|
||||
? selectAtom(derivedAtom, (value) => value, areEqual)
|
||||
: derivedAtom;
|
||||
|
||||
return derivedAtom;
|
||||
finalAtom.debugLabel = `${key}__${cacheKey}`;
|
||||
atomCache.set(cacheKey, finalAtom);
|
||||
|
||||
return finalAtom;
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { atom, type Atom } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
|
||||
import { type ComponentInstanceStateContext } from '@/ui/utilities/state/component-state/types/ComponentInstanceStateContext';
|
||||
import { type ComponentStateKey } from '@/ui/utilities/state/component-state/types/ComponentStateKey';
|
||||
|
|
@ -12,10 +13,12 @@ export const createAtomComponentSelector = <ValueType>({
|
|||
key,
|
||||
get,
|
||||
componentInstanceContext,
|
||||
areEqual,
|
||||
}: {
|
||||
key: string;
|
||||
get: (key: ComponentStateKey) => (callbacks: SelectorGetter) => ValueType;
|
||||
componentInstanceContext: ComponentInstanceStateContext<any> | null;
|
||||
areEqual?: (previous: ValueType, next: ValueType) => boolean;
|
||||
}): ComponentSelector<ValueType> => {
|
||||
if (isDefined(componentInstanceContext)) {
|
||||
globalComponentInstanceContextMap.set(key, componentInstanceContext);
|
||||
|
|
@ -40,10 +43,14 @@ export const createAtomComponentSelector = <ValueType>({
|
|||
return getForKey({ get: getHelper });
|
||||
});
|
||||
|
||||
derivedAtom.debugLabel = `${key}__${componentStateKey.instanceId}`;
|
||||
atomCache.set(componentStateKey.instanceId, derivedAtom);
|
||||
const finalAtom = isDefined(areEqual)
|
||||
? selectAtom(derivedAtom, (value) => value, areEqual)
|
||||
: derivedAtom;
|
||||
|
||||
return derivedAtom;
|
||||
finalAtom.debugLabel = `${key}__${componentStateKey.instanceId}`;
|
||||
atomCache.set(componentStateKey.instanceId, finalAtom);
|
||||
|
||||
return finalAtom;
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import { atom, type Atom } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
|
||||
import { type FamilySelector } from '@/ui/utilities/state/jotai/types/FamilySelector';
|
||||
import { type SelectorGetter } from '@/ui/utilities/state/jotai/types/SelectorCallbacks';
|
||||
import { buildGetHelper } from '@/ui/utilities/state/jotai/utils/buildGetHelper';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const createAtomFamilySelector = <ValueType, FamilyKey>({
|
||||
key,
|
||||
get,
|
||||
areEqual,
|
||||
}: {
|
||||
key: string;
|
||||
get: (familyKey: FamilyKey) => (callbacks: SelectorGetter) => ValueType;
|
||||
areEqual?: (previous: ValueType, next: ValueType) => boolean;
|
||||
}): FamilySelector<ValueType, FamilyKey> => {
|
||||
const atomCache = new Map<string, Atom<ValueType>>();
|
||||
|
||||
|
|
@ -31,10 +35,14 @@ export const createAtomFamilySelector = <ValueType, FamilyKey>({
|
|||
return getForKey({ get: getHelper });
|
||||
});
|
||||
|
||||
derivedAtom.debugLabel = `${key}__${cacheKey}`;
|
||||
atomCache.set(cacheKey, derivedAtom);
|
||||
const finalAtom = isDefined(areEqual)
|
||||
? selectAtom(derivedAtom, (value) => value, areEqual)
|
||||
: derivedAtom;
|
||||
|
||||
return derivedAtom;
|
||||
finalAtom.debugLabel = `${key}__${cacheKey}`;
|
||||
atomCache.set(cacheKey, finalAtom);
|
||||
|
||||
return finalAtom;
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import { atom } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
|
||||
import { type SelectorGetter } from '@/ui/utilities/state/jotai/types/SelectorCallbacks';
|
||||
import { type Selector } from '@/ui/utilities/state/jotai/types/Selector';
|
||||
import { buildGetHelper } from '@/ui/utilities/state/jotai/utils/buildGetHelper';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const createAtomSelector = <ValueType>({
|
||||
key,
|
||||
get,
|
||||
areEqual,
|
||||
}: {
|
||||
key: string;
|
||||
get: (callbacks: SelectorGetter) => ValueType;
|
||||
areEqual?: (previous: ValueType, next: ValueType) => boolean;
|
||||
}): Selector<ValueType> => {
|
||||
const derivedAtom = atom((jotaiGet) => {
|
||||
const getHelper = buildGetHelper(jotaiGet);
|
||||
|
|
@ -17,11 +21,15 @@ export const createAtomSelector = <ValueType>({
|
|||
return get({ get: getHelper });
|
||||
});
|
||||
|
||||
derivedAtom.debugLabel = key;
|
||||
const finalAtom = isDefined(areEqual)
|
||||
? selectAtom(derivedAtom, (value) => value, areEqual)
|
||||
: derivedAtom;
|
||||
|
||||
finalAtom.debugLabel = key;
|
||||
|
||||
return {
|
||||
type: 'Selector',
|
||||
key,
|
||||
atom: derivedAtom,
|
||||
atom: finalAtom,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
|
||||
import { type FlatObjectMetadataItem } from '@/metadata-store/types/FlatObjectMetadataItem';
|
||||
import { type FlatView } from '@/metadata-store/types/FlatView';
|
||||
import { type FlatViewField } from '@/metadata-store/types/FlatViewField';
|
||||
import { type FlatViewFieldGroup } from '@/metadata-store/types/FlatViewFieldGroup';
|
||||
|
|
@ -8,11 +9,21 @@ import { type FlatViewGroup } from '@/metadata-store/types/FlatViewGroup';
|
|||
import { type FlatViewSort } from '@/metadata-store/types/FlatViewSort';
|
||||
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
|
||||
import { type ViewWithRelations } from '@/views/types/ViewWithRelations';
|
||||
import { resolveViewNamePlaceholders } from '@/views/utils/resolveViewNamePlaceholders';
|
||||
|
||||
export const viewsSelector = createAtomSelector<ViewWithRelations[]>({
|
||||
key: 'viewsSelector',
|
||||
get: ({ get }) => {
|
||||
const flatViews = get(metadataStoreState, 'views').current as FlatView[];
|
||||
const flatObjectMetadataItems = get(
|
||||
metadataStoreState,
|
||||
'objectMetadataItems',
|
||||
).current as FlatObjectMetadataItem[];
|
||||
|
||||
const objectMetadataItemsById = new Map(
|
||||
flatObjectMetadataItems.map((item) => [item.id, item]),
|
||||
);
|
||||
|
||||
const flatViewFields = get(metadataStoreState, 'viewFields')
|
||||
.current as FlatViewField[];
|
||||
const flatViewFilters = get(metadataStoreState, 'viewFilters')
|
||||
|
|
@ -75,6 +86,10 @@ export const viewsSelector = createAtomSelector<ViewWithRelations[]>({
|
|||
|
||||
return flatViews.map((view) => ({
|
||||
...view,
|
||||
name: resolveViewNamePlaceholders(
|
||||
view.name,
|
||||
objectMetadataItemsById.get(view.objectMetadataId),
|
||||
),
|
||||
viewFields: viewFieldsByViewId.get(view.id) ?? [],
|
||||
viewFilters: viewFiltersByViewId.get(view.id) ?? [],
|
||||
viewSorts: viewSortsByViewId.get(view.id) ?? [],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import { type FlatObjectMetadataItem } from '@/metadata-store/types/FlatObjectMetadataItem';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const resolveViewNamePlaceholders = (
|
||||
viewName: string,
|
||||
objectMetadataItem: FlatObjectMetadataItem | undefined,
|
||||
): string => {
|
||||
if (!isDefined(objectMetadataItem)) {
|
||||
return viewName;
|
||||
}
|
||||
|
||||
return viewName
|
||||
.replace('{objectLabelPlural}', objectMetadataItem.labelPlural)
|
||||
.replace('{objectLabelSingular}', objectMetadataItem.labelSingular);
|
||||
};
|
||||
|
|
@ -96,8 +96,6 @@ export default defineConfig(({ mode }) => {
|
|||
'**/testing/cache/**',
|
||||
'**/*.test.{ts,tsx}',
|
||||
'**/*.spec.{ts,tsx}',
|
||||
'**/*.stories.{ts,tsx}',
|
||||
'**/__stories__/**',
|
||||
'**/__tests__/**',
|
||||
'**/__mocks__/**',
|
||||
'**/types/**',
|
||||
|
|
|
|||
Loading…
Reference in a new issue