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": {
|
"mcpServers": {
|
||||||
"postgres": {
|
"postgres": {
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"command": "npx",
|
"command": "bash",
|
||||||
"args": ["-y", "@modelcontextprotocol/server-postgres", "${PG_DATABASE_URL}"],
|
"args": ["-c", "source packages/twenty-server/.env && npx -y @modelcontextprotocol/server-postgres \"$PG_DATABASE_URL\""],
|
||||||
"env": {}
|
"env": {}
|
||||||
},
|
},
|
||||||
"playwright": {
|
"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
|
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
|
### GraphQL
|
||||||
```bash
|
```bash
|
||||||
# Generate GraphQL types (run after schema changes)
|
# Generate GraphQL types (run after schema changes)
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ import { type Editor } from '@tiptap/react';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
|
||||||
import { AIChatSuggestedPrompts } from '@/ai/components/suggested-prompts/AIChatSuggestedPrompts';
|
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 { 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 { agentChatErrorState } from '@/ai/states/agentChatErrorState';
|
||||||
import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector';
|
import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector';
|
||||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||||
|
|
@ -26,7 +27,12 @@ type AIChatEmptyStateProps = {
|
||||||
|
|
||||||
export const AIChatEmptyState = ({ editor }: AIChatEmptyStateProps) => {
|
export const AIChatEmptyState = ({ editor }: AIChatEmptyStateProps) => {
|
||||||
const agentChatError = useAtomStateValue(agentChatErrorState);
|
const agentChatError = useAtomStateValue(agentChatErrorState);
|
||||||
const { threadsLoading, messagesLoading } = useAgentChatContext();
|
const agentChatThreadsLoading = useAtomStateValue(
|
||||||
|
agentChatThreadsLoadingState,
|
||||||
|
);
|
||||||
|
const agentChatMessagesLoading = useAtomStateValue(
|
||||||
|
agentChatMessagesLoadingState,
|
||||||
|
);
|
||||||
const skipMessagesSkeletonUntilLoaded = useAtomStateValue(
|
const skipMessagesSkeletonUntilLoaded = useAtomStateValue(
|
||||||
skipMessagesSkeletonUntilLoadedState,
|
skipMessagesSkeletonUntilLoadedState,
|
||||||
);
|
);
|
||||||
|
|
@ -37,11 +43,10 @@ export const AIChatEmptyState = ({ editor }: AIChatEmptyStateProps) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const isOnNewChatSlot =
|
const isOnNewChatSlot =
|
||||||
!isDefined(currentAIChatThread) ||
|
|
||||||
currentAIChatThread === AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
|
currentAIChatThread === AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
|
||||||
const skeletonShowing =
|
const skeletonShowing =
|
||||||
(threadsLoading && isOnNewChatSlot) ||
|
(agentChatThreadsLoading && isOnNewChatSlot) ||
|
||||||
(messagesLoading && !skipMessagesSkeletonUntilLoaded);
|
(agentChatMessagesLoading && !skipMessagesSkeletonUntilLoaded);
|
||||||
const shouldRender =
|
const shouldRender =
|
||||||
!hasMessages && !isDefined(agentChatError) && !skeletonShowing;
|
!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 { AIChatAssistantMessageRenderer } from '@/ai/components/AIChatAssistantMessageRenderer';
|
||||||
import { AIChatErrorRenderer } from '@/ai/components/AIChatErrorRenderer';
|
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 { agentChatMessageComponentFamilySelector } from '@/ai/states/agentChatMessageComponentFamilySelector';
|
||||||
import { agentChatMessageIdsComponentSelector } from '@/ai/states/agentChatMessageIdsComponentSelector';
|
|
||||||
import { LightCopyIconButton } from '@/object-record/record-field/ui/components/LightCopyIconButton';
|
import { LightCopyIconButton } from '@/object-record/record-field/ui/components/LightCopyIconButton';
|
||||||
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
|
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 { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||||
|
|
||||||
import { isExtendedFileUIPart } from 'twenty-shared/ai';
|
import { isExtendedFileUIPart } from 'twenty-shared/ai';
|
||||||
|
|
@ -143,39 +139,37 @@ const StyledFilesContainer = styled.div`
|
||||||
margin-top: ${themeCssVariables.spacing[2]};
|
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(
|
const agentChatMessage = useAtomComponentFamilySelectorValue(
|
||||||
agentChatMessageComponentFamilySelector,
|
agentChatMessageComponentFamilySelector,
|
||||||
{ messageId },
|
{ messageId },
|
||||||
);
|
);
|
||||||
|
|
||||||
const agentChatMessageIds = useAtomComponentSelectorValue(
|
|
||||||
agentChatMessageIdsComponentSelector,
|
|
||||||
);
|
|
||||||
|
|
||||||
const agentChatIsStreaming = useAtomStateValue(agentChatIsStreamingState);
|
|
||||||
|
|
||||||
const agentChatError = useAtomStateValue(agentChatErrorState);
|
|
||||||
|
|
||||||
const { localeCatalog } = useAtomStateValue(dateLocaleState);
|
const { localeCatalog } = useAtomStateValue(dateLocaleState);
|
||||||
|
|
||||||
if (!isDefined(agentChatMessage)) {
|
if (!isDefined(agentChatMessage)) {
|
||||||
return null;
|
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 isUser = agentChatMessage.role === AgentMessageRole.USER;
|
||||||
|
const isLastAssistantMessage =
|
||||||
|
agentChatMessage.role === AgentMessageRole.ASSISTANT;
|
||||||
|
const shouldShowError = isDefined(error) && isLastAssistantMessage;
|
||||||
|
|
||||||
const fileParts = agentChatMessage.parts.filter(isExtendedFileUIPart);
|
const fileParts = agentChatMessage.parts.filter(isExtendedFileUIPart);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledMessageBubble key={agentChatMessage.id} isUser={isUser}>
|
<StyledMessageBubble isUser={isUser}>
|
||||||
<StyledMessageContainer isUser={isUser}>
|
<StyledMessageContainer isUser={isUser}>
|
||||||
<StyledMessageText isUser={isUser}>
|
<StyledMessageText isUser={isUser}>
|
||||||
<AIChatAssistantMessageRenderer
|
<AIChatAssistantMessageRenderer
|
||||||
|
|
@ -191,7 +185,9 @@ export const AIChatMessage = ({ messageId }: { messageId: string }) => {
|
||||||
))}
|
))}
|
||||||
</StyledFilesContainer>
|
</StyledFilesContainer>
|
||||||
)}
|
)}
|
||||||
{shouldShowError && <AIChatErrorRenderer error={agentChatError} />}
|
{shouldShowError && isDefined(error) && (
|
||||||
|
<AIChatErrorRenderer error={error} />
|
||||||
|
)}
|
||||||
</StyledMessageContainer>
|
</StyledMessageContainer>
|
||||||
{agentChatMessage.parts.length > 0 &&
|
{agentChatMessage.parts.length > 0 &&
|
||||||
agentChatMessage.metadata?.createdAt && (
|
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(
|
const threadIdCreatedFromDraft = useAtomStateValue(
|
||||||
threadIdCreatedFromDraftState,
|
threadIdCreatedFromDraftState,
|
||||||
);
|
);
|
||||||
const draftKey = currentAIChatThread ?? AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
|
const draftKey = currentAIChatThread;
|
||||||
const editorSectionKey =
|
const editorSectionKey =
|
||||||
draftKey !== AGENT_CHAT_NEW_THREAD_DRAFT_KEY &&
|
draftKey !== AGENT_CHAT_NEW_THREAD_DRAFT_KEY &&
|
||||||
draftKey === threadIdCreatedFromDraft
|
draftKey === threadIdCreatedFromDraft
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
import { AIChatErrorUnderMessageList } from '@/ai/components/AIChatErrorUnderMessageList';
|
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 { 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 { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||||
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
|
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
|
||||||
|
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||||
import { styled } from '@linaria/react';
|
import { styled } from '@linaria/react';
|
||||||
import { isNonEmptyArray } from '@sniptt/guards';
|
|
||||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||||
|
|
||||||
const StyledScrollWrapperContainer = styled.div`
|
const StyledScrollWrapperContainer = styled.div`
|
||||||
|
|
@ -15,28 +19,38 @@ const StyledScrollWrapperContainer = styled.div`
|
||||||
gap: ${themeCssVariables.spacing[2]};
|
gap: ${themeCssVariables.spacing[2]};
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: ${themeCssVariables.spacing[3]};
|
padding: ${themeCssVariables.spacing[3]};
|
||||||
|
position: relative;
|
||||||
width: calc(100% - 24px);
|
width: calc(100% - 24px);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const AIChatTabMessageList = () => {
|
export const AIChatTabMessageList = () => {
|
||||||
const agentChatMessageIdsComponent = useAtomComponentSelectorValue(
|
const agentChatHasMessage = useAtomComponentSelectorValue(
|
||||||
agentChatMessageIdsComponentSelector,
|
agentChatHasMessageComponentSelector,
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasMessages = isNonEmptyArray(agentChatMessageIdsComponent);
|
const agentChatIsInitialScrollPendingOnThreadChange = useAtomStateValue(
|
||||||
|
agentChatIsInitialScrollPendingOnThreadChangeState,
|
||||||
|
);
|
||||||
|
|
||||||
if (!hasMessages) {
|
if (!agentChatHasMessage) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledScrollWrapperContainer>
|
<StyledScrollWrapperContainer
|
||||||
|
style={{
|
||||||
|
visibility: agentChatIsInitialScrollPendingOnThreadChange
|
||||||
|
? 'hidden'
|
||||||
|
: 'visible',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ScrollWrapper componentInstanceId={AI_CHAT_SCROLL_WRAPPER_ID}>
|
<ScrollWrapper componentInstanceId={AI_CHAT_SCROLL_WRAPPER_ID}>
|
||||||
{agentChatMessageIdsComponent.map((messageId) => {
|
<AIChatNonLastMessageIdsList />
|
||||||
return <AIChatMessage messageId={messageId} key={messageId} />;
|
<AIChatLastMessageWithStreamingState />
|
||||||
})}
|
|
||||||
<AIChatErrorUnderMessageList />
|
<AIChatErrorUnderMessageList />
|
||||||
|
<AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect />
|
||||||
</ScrollWrapper>
|
</ScrollWrapper>
|
||||||
|
<AIChatScrollToBottomButton />
|
||||||
</StyledScrollWrapperContainer>
|
</StyledScrollWrapperContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { styled } from '@linaria/react';
|
import { styled } from '@linaria/react';
|
||||||
|
|
||||||
import { AIChatThreadGroup } from '@/ai/components/AIChatThreadGroup';
|
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 { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader';
|
||||||
import { useChatThreads } from '@/ai/hooks/useChatThreads';
|
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 { groupThreadsByDate } from '@/ai/utils/groupThreadsByDate';
|
||||||
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
|
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
|
||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
|
|
@ -36,7 +36,7 @@ const StyledButtonsContainer = styled.div`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const AIChatThreadsList = () => {
|
export const AIChatThreadsList = () => {
|
||||||
const { switchToNewChat } = useCreateNewAIChatThread();
|
const { switchToNewChat } = useSwitchToNewAIChat();
|
||||||
|
|
||||||
const focusId = 'threads-list';
|
const focusId = 'threads-list';
|
||||||
|
|
||||||
|
|
@ -57,7 +57,7 @@ export const AIChatThreadsList = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AIChatThreadsListEffect focusId={focusId} />
|
<AIChatThreadsListFocusEffect focusId={focusId} />
|
||||||
<StyledContainer>
|
<StyledContainer>
|
||||||
<StyledThreadsContainer>
|
<StyledThreadsContainer>
|
||||||
{Object.entries(groupedThreads).map(([title, threadsInGroup]) => (
|
{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 { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
export const AIChatThreadsListEffect = ({ focusId }: { focusId: string }) => {
|
export const AIChatThreadsListFocusEffect = ({
|
||||||
|
focusId,
|
||||||
|
}: {
|
||||||
|
focusId: string;
|
||||||
|
}) => {
|
||||||
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
|
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
|
||||||
const { removeFocusItemFromFocusStackById } =
|
const { removeFocusItemFromFocusStackById } =
|
||||||
useRemoveFocusItemFromFocusStackById();
|
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 { AgentChatAiSdkStreamEffect } from '@/ai/components/AgentChatAiSdkStreamEffect';
|
||||||
import { AgentChatContext } from '@/ai/contexts/AgentChatContext';
|
import { AgentChatMessagesFetchEffect } from '@/ai/components/AgentChatMessagesFetchEffect';
|
||||||
import { useAgentChatData } from '@/ai/hooks/useAgentChatData';
|
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 { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
|
@ -9,25 +13,19 @@ export const AgentChatProviderContent = ({
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
const { ensureThreadForDraft, threadsLoading, messagesLoading } =
|
|
||||||
useAgentChatData();
|
|
||||||
|
|
||||||
const contextValue = {
|
|
||||||
ensureThreadForDraft,
|
|
||||||
threadsLoading,
|
|
||||||
messagesLoading,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<AgentChatContext.Provider value={contextValue}>
|
<AgentChatComponentInstanceContext.Provider
|
||||||
<AgentChatComponentInstanceContext.Provider
|
value={{ instanceId: 'agentChatComponentInstance' }}
|
||||||
value={{ instanceId: 'agentChatComponentInstance' }}
|
>
|
||||||
>
|
<AgentChatThreadInitializationEffect />
|
||||||
<AgentChatDataEffect />
|
<AgentChatMessagesFetchEffect />
|
||||||
{children}
|
<AgentChatAiSdkStreamEffect />
|
||||||
</AgentChatComponentInstanceContext.Provider>
|
<AgentChatStreamingPartsDiffSyncEffect />
|
||||||
</AgentChatContext.Provider>
|
<AgentChatSessionStartTimeEffect />
|
||||||
|
<AgentChatStreamingAutoScrollEffect />
|
||||||
|
{children}
|
||||||
|
</AgentChatComponentInstanceContext.Provider>
|
||||||
</Suspense>
|
</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 { AIChatMessage } from '@/ai/components/AIChatMessage';
|
||||||
|
|
||||||
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
|
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
|
||||||
|
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
|
||||||
import { agentChatMessageComponentFamilyState } from '@/ai/states/agentChatMessageComponentFamilyState';
|
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 { styled } from '@linaria/react';
|
||||||
import { useStore } from 'jotai';
|
import { useStore } from 'jotai';
|
||||||
import { RootDecorator } from '~/testing/decorators/RootDecorator';
|
import { RootDecorator } from '~/testing/decorators/RootDecorator';
|
||||||
|
|
@ -255,8 +258,15 @@ const AgentChatMessagesSetterEffect = ({
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const currentThreadId = store.get(currentAIChatThreadState.atom);
|
||||||
|
|
||||||
|
store.set(agentChatDisplayedThreadState.atom, currentThreadId);
|
||||||
|
|
||||||
store.set(
|
store.set(
|
||||||
agentChatMessagesComponentState.atomFamily({ instanceId: INSTANCE_ID }),
|
agentChatMessagesComponentFamilyState.atomFamily({
|
||||||
|
instanceId: INSTANCE_ID,
|
||||||
|
familyKey: { threadId: currentThreadId },
|
||||||
|
}),
|
||||||
messages,
|
messages,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { styled } from '@linaria/react';
|
import { styled } from '@linaria/react';
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
|
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
|
||||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
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 { 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 { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector';
|
||||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||||
import { skipMessagesSkeletonUntilLoadedState } from '@/ai/states/skipMessagesSkeletonUntilLoadedState';
|
import { skipMessagesSkeletonUntilLoadedState } from '@/ai/states/skipMessagesSkeletonUntilLoadedState';
|
||||||
|
|
@ -34,7 +34,12 @@ const NUMBER_OF_SKELETONS = 6;
|
||||||
|
|
||||||
export const AIChatSkeletonLoader = () => {
|
export const AIChatSkeletonLoader = () => {
|
||||||
const { theme } = useContext(ThemeContext);
|
const { theme } = useContext(ThemeContext);
|
||||||
const { threadsLoading, messagesLoading } = useAgentChatContext();
|
const agentChatThreadsLoading = useAtomStateValue(
|
||||||
|
agentChatThreadsLoadingState,
|
||||||
|
);
|
||||||
|
const agentChatMessagesLoading = useAtomStateValue(
|
||||||
|
agentChatMessagesLoadingState,
|
||||||
|
);
|
||||||
const skipMessagesSkeletonUntilLoaded = useAtomStateValue(
|
const skipMessagesSkeletonUntilLoaded = useAtomStateValue(
|
||||||
skipMessagesSkeletonUntilLoadedState,
|
skipMessagesSkeletonUntilLoadedState,
|
||||||
);
|
);
|
||||||
|
|
@ -45,13 +50,12 @@ export const AIChatSkeletonLoader = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const isOnNewChatSlot =
|
const isOnNewChatSlot =
|
||||||
!isDefined(currentAIChatThread) ||
|
|
||||||
currentAIChatThread === AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
|
currentAIChatThread === AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
|
||||||
const showForMessagesLoading =
|
const showForMessagesLoading =
|
||||||
messagesLoading && !skipMessagesSkeletonUntilLoaded;
|
agentChatMessagesLoading && !skipMessagesSkeletonUntilLoaded;
|
||||||
const shouldRender =
|
const shouldRender =
|
||||||
!hasMessages &&
|
!hasMessages &&
|
||||||
((threadsLoading && isOnNewChatSlot) || showForMessagesLoading);
|
((agentChatThreadsLoading && isOnNewChatSlot) || showForMessagesLoading);
|
||||||
|
|
||||||
if (!shouldRender) {
|
if (!shouldRender) {
|
||||||
return null;
|
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 { useMemo } from 'react';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
|
||||||
import { useAgentChatContext } from '@/ai/contexts/AgentChatContext';
|
|
||||||
import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
|
import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
|
||||||
import {
|
import {
|
||||||
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
|
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
|
||||||
|
|
@ -16,6 +15,7 @@ import {
|
||||||
} from '@/ai/states/agentChatDraftsByThreadIdState';
|
} from '@/ai/states/agentChatDraftsByThreadIdState';
|
||||||
import { agentChatInputState } from '@/ai/states/agentChatInputState';
|
import { agentChatInputState } from '@/ai/states/agentChatInputState';
|
||||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||||
|
import { dispatchAgentChatEnsureThreadForDraftEvent } from '@/ai/utils/dispatchAgentChatEnsureThreadForDraftEvent';
|
||||||
import { dispatchAgentChatSendMessageEvent } from '@/ai/utils/dispatchAgentChatSendMessageEvent';
|
import { dispatchAgentChatSendMessageEvent } from '@/ai/utils/dispatchAgentChatSendMessageEvent';
|
||||||
import { MENTION_SUGGESTION_PLUGIN_KEY } from '@/mention/constants/MentionSuggestionPluginKey';
|
import { MENTION_SUGGESTION_PLUGIN_KEY } from '@/mention/constants/MentionSuggestionPluginKey';
|
||||||
import { MentionSuggestion } from '@/mention/extensions/MentionSuggestion';
|
import { MentionSuggestion } from '@/mention/extensions/MentionSuggestion';
|
||||||
|
|
@ -44,14 +44,12 @@ export const useAIChatEditor = () => {
|
||||||
const currentAIChatThread = useAtomStateValue(currentAIChatThreadState);
|
const currentAIChatThread = useAtomStateValue(currentAIChatThreadState);
|
||||||
const [agentChatDraftsByThreadId, setAgentChatDraftsByThreadId] =
|
const [agentChatDraftsByThreadId, setAgentChatDraftsByThreadId] =
|
||||||
useAtomState(agentChatDraftsByThreadIdState);
|
useAtomState(agentChatDraftsByThreadIdState);
|
||||||
const { ensureThreadForDraft } = useAgentChatContext();
|
|
||||||
|
|
||||||
const { searchMentionRecords } = useMentionSearch();
|
const { searchMentionRecords } = useMentionSearch();
|
||||||
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
|
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
|
||||||
const { removeFocusItemFromFocusStackById } =
|
const { removeFocusItemFromFocusStackById } =
|
||||||
useRemoveFocusItemFromFocusStackById();
|
useRemoveFocusItemFromFocusStackById();
|
||||||
|
|
||||||
const draftKey = currentAIChatThread ?? AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
|
const draftKey = currentAIChatThread;
|
||||||
const initialDraft = agentChatDraftsByThreadId[draftKey] ?? '';
|
const initialDraft = agentChatDraftsByThreadId[draftKey] ?? '';
|
||||||
const initialContent = textToTiptapContent(initialDraft);
|
const initialContent = textToTiptapContent(initialDraft);
|
||||||
|
|
||||||
|
|
@ -102,7 +100,7 @@ export const useAIChatEditor = () => {
|
||||||
setAgentChatInput(text);
|
setAgentChatInput(text);
|
||||||
setAgentChatDraftsByThreadId((prev) => ({ ...prev, [draftKey]: text }));
|
setAgentChatDraftsByThreadId((prev) => ({ ...prev, [draftKey]: text }));
|
||||||
if (draftKey === AGENT_CHAT_NEW_THREAD_DRAFT_KEY && text.trim() !== '') {
|
if (draftKey === AGENT_CHAT_NEW_THREAD_DRAFT_KEY && text.trim() !== '') {
|
||||||
ensureThreadForDraft?.();
|
dispatchAgentChatEnsureThreadForDraftEvent();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onFocus: () => {
|
onFocus: () => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
import {
|
import { agentChatDraftsByThreadIdState } from '@/ai/states/agentChatDraftsByThreadIdState';
|
||||||
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
|
|
||||||
agentChatDraftsByThreadIdState,
|
|
||||||
} from '@/ai/states/agentChatDraftsByThreadIdState';
|
|
||||||
import { agentChatInputState } from '@/ai/states/agentChatInputState';
|
import { agentChatInputState } from '@/ai/states/agentChatInputState';
|
||||||
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
|
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
|
||||||
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
|
||||||
|
|
@ -41,8 +38,7 @@ export const useAIChatThreadClick = (
|
||||||
|
|
||||||
const handleThreadClick = (thread: AgentChatThread) => {
|
const handleThreadClick = (thread: AgentChatThread) => {
|
||||||
setThreadIdCreatedFromDraft(null);
|
setThreadIdCreatedFromDraft(null);
|
||||||
const previousDraftKey =
|
const previousDraftKey = currentAIChatThread;
|
||||||
currentAIChatThread ?? AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
|
|
||||||
const isSameThread = thread.id === currentAIChatThread;
|
const isSameThread = thread.id === currentAIChatThread;
|
||||||
|
|
||||||
setAgentChatDraftsByThreadId((prev) => ({
|
setAgentChatDraftsByThreadId((prev) => ({
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import { cookieStorage } from '~/utils/cookie-storage';
|
||||||
export const useAgentChat = (
|
export const useAgentChat = (
|
||||||
uiMessages: ExtendedUIMessage[],
|
uiMessages: ExtendedUIMessage[],
|
||||||
ensureThreadIdForSend: () => Promise<string | null>,
|
ensureThreadIdForSend: () => Promise<string | null>,
|
||||||
|
onStreamingComplete?: () => void,
|
||||||
) => {
|
) => {
|
||||||
const setTokenPair = useSetAtomState(tokenPairState);
|
const setTokenPair = useSetAtomState(tokenPairState);
|
||||||
const setAgentChatUsage = useSetAtomState(agentChatUsageState);
|
const setAgentChatUsage = useSetAtomState(agentChatUsageState);
|
||||||
|
|
@ -206,6 +207,8 @@ export const useAgentChat = (
|
||||||
}
|
}
|
||||||
return null;
|
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 { Temporal } from 'temporal-polyfill';
|
||||||
import { type ExtendedUIMessage } from 'twenty-shared/ai';
|
import { type ExtendedUIMessage } from 'twenty-shared/ai';
|
||||||
|
|
||||||
export const useProcessNewMessageStreamIncrement = () => {
|
export const useProcessStreamingMessageUpdate = () => {
|
||||||
const agentChatUISessionStartTime = useAtomStateValue(
|
const agentChatUISessionStartTime = useAtomStateValue(
|
||||||
agentChatUISessionStartTimeState,
|
agentChatUISessionStartTimeState,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { processUIToolCallMessage } = useProcessUIToolCallMessage();
|
const { processUIToolCallMessage } = useProcessUIToolCallMessage();
|
||||||
|
|
||||||
const processNewMessageStreamIncrement = (
|
const processStreamingMessageUpdate = (
|
||||||
messageStreamIncrement: ExtendedUIMessage,
|
streamingMessage: ExtendedUIMessage,
|
||||||
) => {
|
) => {
|
||||||
if (agentChatUISessionStartTime === null) {
|
if (agentChatUISessionStartTime === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageCreatedAt = messageStreamIncrement.metadata?.createdAt;
|
const messageCreatedAt = streamingMessage.metadata?.createdAt;
|
||||||
|
|
||||||
if (isNonEmptyString(messageCreatedAt)) {
|
if (isNonEmptyString(messageCreatedAt)) {
|
||||||
const messageCreatedAtInstant = Temporal.Instant.from(messageCreatedAt);
|
const messageCreatedAtInstant = Temporal.Instant.from(messageCreatedAt);
|
||||||
|
|
@ -34,14 +34,14 @@ export const useProcessNewMessageStreamIncrement = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageIsUIToolCall = isUIToolCallMessage(messageStreamIncrement);
|
const messageIsUIToolCall = isUIToolCallMessage(streamingMessage);
|
||||||
|
|
||||||
if (messageIsUIToolCall) {
|
if (messageIsUIToolCall) {
|
||||||
processUIToolCallMessage(messageStreamIncrement);
|
processUIToolCallMessage(streamingMessage);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
processNewMessageStreamIncrement,
|
processStreamingMessageUpdate,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -14,7 +14,7 @@ import { useOpenAskAIPageInSidePanel } from '@/side-panel/hooks/useOpenAskAIPage
|
||||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||||
|
|
||||||
export const useCreateNewAIChatThread = () => {
|
export const useSwitchToNewAIChat = () => {
|
||||||
const setThreadIdCreatedFromDraft = useSetAtomState(
|
const setThreadIdCreatedFromDraft = useSetAtomState(
|
||||||
threadIdCreatedFromDraftState,
|
threadIdCreatedFromDraftState,
|
||||||
);
|
);
|
||||||
|
|
@ -34,8 +34,7 @@ export const useCreateNewAIChatThread = () => {
|
||||||
|
|
||||||
const switchToNewChat = () => {
|
const switchToNewChat = () => {
|
||||||
setThreadIdCreatedFromDraft(null);
|
setThreadIdCreatedFromDraft(null);
|
||||||
const previousDraftKey =
|
const previousDraftKey = currentAIChatThread;
|
||||||
currentAIChatThread ?? AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
|
|
||||||
const newChatDraft =
|
const newChatDraft =
|
||||||
store.get(agentChatDraftsByThreadIdState.atom)[
|
store.get(agentChatDraftsByThreadIdState.atom)[
|
||||||
AGENT_CHAT_NEW_THREAD_DRAFT_KEY
|
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 { agentChatMessageComponentFamilyState } from '@/ai/states/agentChatMessageComponentFamilyState';
|
||||||
import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateCallbackState';
|
import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateCallbackState';
|
||||||
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
|
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 { isDefined } from 'twenty-shared/utils';
|
||||||
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
|
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
|
||||||
|
|
||||||
export const useProcessIncrementalStreamMessages = () => {
|
export const useUpdateStreamingPartsWithDiff = () => {
|
||||||
const agentChatMessageFamilyCallbackState =
|
const agentChatMessageFamilyCallbackState =
|
||||||
useAtomComponentFamilyStateCallbackState(
|
useAtomComponentFamilyStateCallbackState(
|
||||||
agentChatMessageComponentFamilyState,
|
agentChatMessageComponentFamilyState,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { processNewMessageStreamIncrement } =
|
const { processStreamingMessageUpdate } = useProcessStreamingMessageUpdate();
|
||||||
useProcessNewMessageStreamIncrement();
|
|
||||||
|
|
||||||
const processIncrementalStreamMessages = useCallback(
|
const updateStreamingPartsWithDiff = useCallback(
|
||||||
(incrementalStreamMessages: ExtendedUIMessage[]) => {
|
(incomingMessages: ExtendedUIMessage[]) => {
|
||||||
for (const updatedMessage of incrementalStreamMessages) {
|
for (const incomingMessage of incomingMessages) {
|
||||||
const alreadyExistingMessage = jotaiStore.get(
|
const alreadyExistingMessage = jotaiStore.get(
|
||||||
agentChatMessageFamilyCallbackState(updatedMessage.id),
|
agentChatMessageFamilyCallbackState(incomingMessage.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
const messageContentHasChanged = !isDeeplyEqual(
|
const messageContentHasChanged = !isDeeplyEqual(
|
||||||
alreadyExistingMessage,
|
alreadyExistingMessage,
|
||||||
updatedMessage,
|
incomingMessage,
|
||||||
);
|
);
|
||||||
|
|
||||||
const messageAlreadyExists = isDefined(alreadyExistingMessage);
|
const messageAlreadyExists = isDefined(alreadyExistingMessage);
|
||||||
|
|
@ -37,20 +36,20 @@ export const useProcessIncrementalStreamMessages = () => {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clonedMessage = structuredClone(updatedMessage);
|
const clonedMessage = structuredClone(incomingMessage);
|
||||||
|
|
||||||
jotaiStore.set(
|
jotaiStore.set(
|
||||||
agentChatMessageFamilyCallbackState(updatedMessage.id),
|
agentChatMessageFamilyCallbackState(incomingMessage.id),
|
||||||
clonedMessage,
|
clonedMessage,
|
||||||
);
|
);
|
||||||
|
|
||||||
processNewMessageStreamIncrement(updatedMessage);
|
processStreamingMessageUpdate(incomingMessage);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[agentChatMessageFamilyCallbackState, processNewMessageStreamIncrement],
|
[agentChatMessageFamilyCallbackState, processStreamingMessageUpdate],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
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 { 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 { createAtomComponentSelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentSelector';
|
||||||
import { isNonEmptyArray } from '@sniptt/guards';
|
import { isNonEmptyArray } from '@sniptt/guards';
|
||||||
|
|
||||||
|
|
@ -10,7 +11,12 @@ export const agentChatHasMessageComponentSelector =
|
||||||
get:
|
get:
|
||||||
({ instanceId }) =>
|
({ instanceId }) =>
|
||||||
({ get }) => {
|
({ get }) => {
|
||||||
const messages = get(agentChatMessagesComponentState, { instanceId });
|
const currentThreadId = get(agentChatDisplayedThreadState);
|
||||||
|
|
||||||
|
const messages = get(agentChatMessagesComponentFamilyState, {
|
||||||
|
instanceId,
|
||||||
|
familyKey: { threadId: currentThreadId },
|
||||||
|
});
|
||||||
|
|
||||||
return isNonEmptyArray(messages);
|
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 { 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 { createAtomComponentFamilySelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilySelector';
|
||||||
import { type ExtendedUIMessage } from 'twenty-shared/ai';
|
import { type ExtendedUIMessage } from 'twenty-shared/ai';
|
||||||
import { type Nullable } from 'twenty-shared/types';
|
import { type Nullable } from 'twenty-shared/types';
|
||||||
|
|
@ -13,7 +14,12 @@ export const agentChatMessageComponentFamilySelector =
|
||||||
get:
|
get:
|
||||||
({ instanceId, familyKey: { messageId } }) =>
|
({ instanceId, familyKey: { messageId } }) =>
|
||||||
({ get }) => {
|
({ 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);
|
return messages.find((message) => message.id === messageId);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
|
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 { createAtomComponentSelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentSelector';
|
||||||
|
|
||||||
export const agentChatMessageIdsComponentSelector = createAtomComponentSelector<
|
export const agentChatMessageIdsComponentSelector = createAtomComponentSelector<
|
||||||
|
|
@ -10,7 +11,12 @@ export const agentChatMessageIdsComponentSelector = createAtomComponentSelector<
|
||||||
get:
|
get:
|
||||||
({ instanceId }) =>
|
({ instanceId }) =>
|
||||||
({ get }) => {
|
({ get }) => {
|
||||||
const messages = get(agentChatMessagesComponentState, { instanceId });
|
const currentThreadId = get(agentChatDisplayedThreadState);
|
||||||
|
|
||||||
|
const messages = get(agentChatMessagesComponentFamilyState, {
|
||||||
|
instanceId,
|
||||||
|
familyKey: { threadId: currentThreadId },
|
||||||
|
});
|
||||||
|
|
||||||
return messages.map((message) => message.id);
|
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';
|
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||||
|
|
||||||
export const currentAIChatThreadState = createAtomState<string | null>({
|
export const currentAIChatThreadState = createAtomState<string>({
|
||||||
key: 'ai/currentAIChatThreadState',
|
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 { 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 { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
|
||||||
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
|
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
|
||||||
import { navigationDrawerActiveTabState } from '@/ui/navigation/states/navigationDrawerActiveTabState';
|
import { navigationDrawerActiveTabState } from '@/ui/navigation/states/navigationDrawerActiveTabState';
|
||||||
|
|
@ -127,7 +127,7 @@ export const MainNavigationDrawerTabsRow = () => {
|
||||||
);
|
);
|
||||||
const [navigationDrawerActiveTab, setNavigationDrawerActiveTab] =
|
const [navigationDrawerActiveTab, setNavigationDrawerActiveTab] =
|
||||||
useAtomState(navigationDrawerActiveTabState);
|
useAtomState(navigationDrawerActiveTabState);
|
||||||
const { switchToNewChat } = useCreateNewAIChatThread();
|
const { switchToNewChat } = useSwitchToNewAIChat();
|
||||||
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
|
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
|
||||||
const setIsNavigationDrawerExpanded = useSetAtomState(
|
const setIsNavigationDrawerExpanded = useSetAtomState(
|
||||||
isNavigationDrawerExpandedState,
|
isNavigationDrawerExpandedState,
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
|
import { useSwitchToNewAIChat } from '@/ai/hooks/useSwitchToNewAIChat';
|
||||||
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
|
|
||||||
import { useOpenRecordsSearchPageInSidePanel } from '@/side-panel/hooks/useOpenRecordsSearchPageInSidePanel';
|
|
||||||
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
|
|
||||||
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
|
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
|
||||||
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
|
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
|
||||||
import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath';
|
import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath';
|
||||||
import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage';
|
import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage';
|
||||||
import { currentMobileNavigationDrawerState } from '@/navigation/states/currentMobileNavigationDrawerState';
|
import { currentMobileNavigationDrawerState } from '@/navigation/states/currentMobileNavigationDrawerState';
|
||||||
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
|
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 { 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 { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||||
|
|
@ -36,12 +36,12 @@ export const MobileNavigationBar = () => {
|
||||||
useAtomState(isNavigationDrawerExpandedState);
|
useAtomState(isNavigationDrawerExpandedState);
|
||||||
const [currentMobileNavigationDrawer, setCurrentMobileNavigationDrawer] =
|
const [currentMobileNavigationDrawer, setCurrentMobileNavigationDrawer] =
|
||||||
useAtomState(currentMobileNavigationDrawerState);
|
useAtomState(currentMobileNavigationDrawerState);
|
||||||
const { switchToNewChat } = useCreateNewAIChatThread();
|
const { switchToNewChat } = useSwitchToNewAIChat();
|
||||||
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
|
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
|
||||||
const { alphaSortedActiveNonSystemObjectMetadataItems } =
|
const { alphaSortedActiveNonSystemObjectMetadataItems } =
|
||||||
useFilteredObjectMetadataItems();
|
useFilteredObjectMetadataItems();
|
||||||
|
|
||||||
const [, setContextStoreCurrentObjectMetadataItemId] = useAtomComponentState(
|
const setContextStoreCurrentObjectMetadataItemId = useSetAtomComponentState(
|
||||||
contextStoreCurrentObjectMetadataItemIdComponentState,
|
contextStoreCurrentObjectMetadataItemIdComponentState,
|
||||||
MAIN_CONTEXT_STORE_INSTANCE_ID,
|
MAIN_CONTEXT_STORE_INSTANCE_ID,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { IconButton } from 'twenty-ui/input';
|
||||||
import { useIsMobile } from 'twenty-ui/utilities';
|
import { useIsMobile } from 'twenty-ui/utilities';
|
||||||
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
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';
|
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||||
|
|
||||||
const StyledIconButtonContainer = styled.div`
|
const StyledIconButtonContainer = styled.div`
|
||||||
|
|
@ -22,7 +22,7 @@ export const SidePanelTopBarRightCornerIcon = () => {
|
||||||
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
|
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
|
||||||
const sidePanelPage = useAtomStateValue(sidePanelPageState);
|
const sidePanelPage = useAtomStateValue(sidePanelPageState);
|
||||||
const { openAskAIPage } = useOpenAskAIPageInSidePanel();
|
const { openAskAIPage } = useOpenAskAIPageInSidePanel();
|
||||||
const { switchToNewChat } = useCreateNewAIChatThread();
|
const { switchToNewChat } = useSwitchToNewAIChat();
|
||||||
|
|
||||||
if (isMobile || !isAiEnabled) {
|
if (isMobile || !isAiEnabled) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { atom, type Atom } from 'jotai';
|
import { atom, type Atom } from 'jotai';
|
||||||
|
import { selectAtom } from 'jotai/utils';
|
||||||
|
|
||||||
import { type ComponentInstanceStateContext } from '@/ui/utilities/state/component-state/types/ComponentInstanceStateContext';
|
import { type ComponentInstanceStateContext } from '@/ui/utilities/state/component-state/types/ComponentInstanceStateContext';
|
||||||
import { globalComponentInstanceContextMap } from '@/ui/utilities/state/component-state/utils/globalComponentInstanceContextMap';
|
import { globalComponentInstanceContextMap } from '@/ui/utilities/state/component-state/utils/globalComponentInstanceContextMap';
|
||||||
|
|
@ -12,12 +13,14 @@ export const createAtomComponentFamilySelector = <ValueType, FamilyKey>({
|
||||||
key,
|
key,
|
||||||
get,
|
get,
|
||||||
componentInstanceContext,
|
componentInstanceContext,
|
||||||
|
areEqual,
|
||||||
}: {
|
}: {
|
||||||
key: string;
|
key: string;
|
||||||
get: (
|
get: (
|
||||||
key: ComponentFamilyStateKey<FamilyKey>,
|
key: ComponentFamilyStateKey<FamilyKey>,
|
||||||
) => (callbacks: SelectorGetter) => ValueType;
|
) => (callbacks: SelectorGetter) => ValueType;
|
||||||
componentInstanceContext: ComponentInstanceStateContext<any> | null;
|
componentInstanceContext: ComponentInstanceStateContext<any> | null;
|
||||||
|
areEqual?: (previous: ValueType, next: ValueType) => boolean;
|
||||||
}): ComponentFamilySelector<ValueType, FamilyKey> => {
|
}): ComponentFamilySelector<ValueType, FamilyKey> => {
|
||||||
if (isDefined(componentInstanceContext)) {
|
if (isDefined(componentInstanceContext)) {
|
||||||
globalComponentInstanceContextMap.set(key, componentInstanceContext);
|
globalComponentInstanceContextMap.set(key, componentInstanceContext);
|
||||||
|
|
@ -47,10 +50,14 @@ export const createAtomComponentFamilySelector = <ValueType, FamilyKey>({
|
||||||
return getForKey({ get: getHelper });
|
return getForKey({ get: getHelper });
|
||||||
});
|
});
|
||||||
|
|
||||||
derivedAtom.debugLabel = `${key}__${cacheKey}`;
|
const finalAtom = isDefined(areEqual)
|
||||||
atomCache.set(cacheKey, derivedAtom);
|
? selectAtom(derivedAtom, (value) => value, areEqual)
|
||||||
|
: derivedAtom;
|
||||||
|
|
||||||
return derivedAtom;
|
finalAtom.debugLabel = `${key}__${cacheKey}`;
|
||||||
|
atomCache.set(cacheKey, finalAtom);
|
||||||
|
|
||||||
|
return finalAtom;
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { atom, type Atom } from 'jotai';
|
import { atom, type Atom } from 'jotai';
|
||||||
|
import { selectAtom } from 'jotai/utils';
|
||||||
|
|
||||||
import { type ComponentInstanceStateContext } from '@/ui/utilities/state/component-state/types/ComponentInstanceStateContext';
|
import { type ComponentInstanceStateContext } from '@/ui/utilities/state/component-state/types/ComponentInstanceStateContext';
|
||||||
import { type ComponentStateKey } from '@/ui/utilities/state/component-state/types/ComponentStateKey';
|
import { type ComponentStateKey } from '@/ui/utilities/state/component-state/types/ComponentStateKey';
|
||||||
|
|
@ -12,10 +13,12 @@ export const createAtomComponentSelector = <ValueType>({
|
||||||
key,
|
key,
|
||||||
get,
|
get,
|
||||||
componentInstanceContext,
|
componentInstanceContext,
|
||||||
|
areEqual,
|
||||||
}: {
|
}: {
|
||||||
key: string;
|
key: string;
|
||||||
get: (key: ComponentStateKey) => (callbacks: SelectorGetter) => ValueType;
|
get: (key: ComponentStateKey) => (callbacks: SelectorGetter) => ValueType;
|
||||||
componentInstanceContext: ComponentInstanceStateContext<any> | null;
|
componentInstanceContext: ComponentInstanceStateContext<any> | null;
|
||||||
|
areEqual?: (previous: ValueType, next: ValueType) => boolean;
|
||||||
}): ComponentSelector<ValueType> => {
|
}): ComponentSelector<ValueType> => {
|
||||||
if (isDefined(componentInstanceContext)) {
|
if (isDefined(componentInstanceContext)) {
|
||||||
globalComponentInstanceContextMap.set(key, componentInstanceContext);
|
globalComponentInstanceContextMap.set(key, componentInstanceContext);
|
||||||
|
|
@ -40,10 +43,14 @@ export const createAtomComponentSelector = <ValueType>({
|
||||||
return getForKey({ get: getHelper });
|
return getForKey({ get: getHelper });
|
||||||
});
|
});
|
||||||
|
|
||||||
derivedAtom.debugLabel = `${key}__${componentStateKey.instanceId}`;
|
const finalAtom = isDefined(areEqual)
|
||||||
atomCache.set(componentStateKey.instanceId, derivedAtom);
|
? selectAtom(derivedAtom, (value) => value, areEqual)
|
||||||
|
: derivedAtom;
|
||||||
|
|
||||||
return derivedAtom;
|
finalAtom.debugLabel = `${key}__${componentStateKey.instanceId}`;
|
||||||
|
atomCache.set(componentStateKey.instanceId, finalAtom);
|
||||||
|
|
||||||
|
return finalAtom;
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
import { atom, type Atom } from 'jotai';
|
import { atom, type Atom } from 'jotai';
|
||||||
|
import { selectAtom } from 'jotai/utils';
|
||||||
|
|
||||||
import { type FamilySelector } from '@/ui/utilities/state/jotai/types/FamilySelector';
|
import { type FamilySelector } from '@/ui/utilities/state/jotai/types/FamilySelector';
|
||||||
import { type SelectorGetter } from '@/ui/utilities/state/jotai/types/SelectorCallbacks';
|
import { type SelectorGetter } from '@/ui/utilities/state/jotai/types/SelectorCallbacks';
|
||||||
import { buildGetHelper } from '@/ui/utilities/state/jotai/utils/buildGetHelper';
|
import { buildGetHelper } from '@/ui/utilities/state/jotai/utils/buildGetHelper';
|
||||||
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
|
||||||
export const createAtomFamilySelector = <ValueType, FamilyKey>({
|
export const createAtomFamilySelector = <ValueType, FamilyKey>({
|
||||||
key,
|
key,
|
||||||
get,
|
get,
|
||||||
|
areEqual,
|
||||||
}: {
|
}: {
|
||||||
key: string;
|
key: string;
|
||||||
get: (familyKey: FamilyKey) => (callbacks: SelectorGetter) => ValueType;
|
get: (familyKey: FamilyKey) => (callbacks: SelectorGetter) => ValueType;
|
||||||
|
areEqual?: (previous: ValueType, next: ValueType) => boolean;
|
||||||
}): FamilySelector<ValueType, FamilyKey> => {
|
}): FamilySelector<ValueType, FamilyKey> => {
|
||||||
const atomCache = new Map<string, Atom<ValueType>>();
|
const atomCache = new Map<string, Atom<ValueType>>();
|
||||||
|
|
||||||
|
|
@ -31,10 +35,14 @@ export const createAtomFamilySelector = <ValueType, FamilyKey>({
|
||||||
return getForKey({ get: getHelper });
|
return getForKey({ get: getHelper });
|
||||||
});
|
});
|
||||||
|
|
||||||
derivedAtom.debugLabel = `${key}__${cacheKey}`;
|
const finalAtom = isDefined(areEqual)
|
||||||
atomCache.set(cacheKey, derivedAtom);
|
? selectAtom(derivedAtom, (value) => value, areEqual)
|
||||||
|
: derivedAtom;
|
||||||
|
|
||||||
return derivedAtom;
|
finalAtom.debugLabel = `${key}__${cacheKey}`;
|
||||||
|
atomCache.set(cacheKey, finalAtom);
|
||||||
|
|
||||||
|
return finalAtom;
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
import { atom } from 'jotai';
|
import { atom } from 'jotai';
|
||||||
|
import { selectAtom } from 'jotai/utils';
|
||||||
|
|
||||||
import { type SelectorGetter } from '@/ui/utilities/state/jotai/types/SelectorCallbacks';
|
import { type SelectorGetter } from '@/ui/utilities/state/jotai/types/SelectorCallbacks';
|
||||||
import { type Selector } from '@/ui/utilities/state/jotai/types/Selector';
|
import { type Selector } from '@/ui/utilities/state/jotai/types/Selector';
|
||||||
import { buildGetHelper } from '@/ui/utilities/state/jotai/utils/buildGetHelper';
|
import { buildGetHelper } from '@/ui/utilities/state/jotai/utils/buildGetHelper';
|
||||||
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
|
||||||
export const createAtomSelector = <ValueType>({
|
export const createAtomSelector = <ValueType>({
|
||||||
key,
|
key,
|
||||||
get,
|
get,
|
||||||
|
areEqual,
|
||||||
}: {
|
}: {
|
||||||
key: string;
|
key: string;
|
||||||
get: (callbacks: SelectorGetter) => ValueType;
|
get: (callbacks: SelectorGetter) => ValueType;
|
||||||
|
areEqual?: (previous: ValueType, next: ValueType) => boolean;
|
||||||
}): Selector<ValueType> => {
|
}): Selector<ValueType> => {
|
||||||
const derivedAtom = atom((jotaiGet) => {
|
const derivedAtom = atom((jotaiGet) => {
|
||||||
const getHelper = buildGetHelper(jotaiGet);
|
const getHelper = buildGetHelper(jotaiGet);
|
||||||
|
|
@ -17,11 +21,15 @@ export const createAtomSelector = <ValueType>({
|
||||||
return get({ get: getHelper });
|
return get({ get: getHelper });
|
||||||
});
|
});
|
||||||
|
|
||||||
derivedAtom.debugLabel = key;
|
const finalAtom = isDefined(areEqual)
|
||||||
|
? selectAtom(derivedAtom, (value) => value, areEqual)
|
||||||
|
: derivedAtom;
|
||||||
|
|
||||||
|
finalAtom.debugLabel = key;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'Selector',
|
type: 'Selector',
|
||||||
key,
|
key,
|
||||||
atom: derivedAtom,
|
atom: finalAtom,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
|
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 FlatView } from '@/metadata-store/types/FlatView';
|
||||||
import { type FlatViewField } from '@/metadata-store/types/FlatViewField';
|
import { type FlatViewField } from '@/metadata-store/types/FlatViewField';
|
||||||
import { type FlatViewFieldGroup } from '@/metadata-store/types/FlatViewFieldGroup';
|
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 { type FlatViewSort } from '@/metadata-store/types/FlatViewSort';
|
||||||
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
|
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
|
||||||
import { type ViewWithRelations } from '@/views/types/ViewWithRelations';
|
import { type ViewWithRelations } from '@/views/types/ViewWithRelations';
|
||||||
|
import { resolveViewNamePlaceholders } from '@/views/utils/resolveViewNamePlaceholders';
|
||||||
|
|
||||||
export const viewsSelector = createAtomSelector<ViewWithRelations[]>({
|
export const viewsSelector = createAtomSelector<ViewWithRelations[]>({
|
||||||
key: 'viewsSelector',
|
key: 'viewsSelector',
|
||||||
get: ({ get }) => {
|
get: ({ get }) => {
|
||||||
const flatViews = get(metadataStoreState, 'views').current as FlatView[];
|
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')
|
const flatViewFields = get(metadataStoreState, 'viewFields')
|
||||||
.current as FlatViewField[];
|
.current as FlatViewField[];
|
||||||
const flatViewFilters = get(metadataStoreState, 'viewFilters')
|
const flatViewFilters = get(metadataStoreState, 'viewFilters')
|
||||||
|
|
@ -75,6 +86,10 @@ export const viewsSelector = createAtomSelector<ViewWithRelations[]>({
|
||||||
|
|
||||||
return flatViews.map((view) => ({
|
return flatViews.map((view) => ({
|
||||||
...view,
|
...view,
|
||||||
|
name: resolveViewNamePlaceholders(
|
||||||
|
view.name,
|
||||||
|
objectMetadataItemsById.get(view.objectMetadataId),
|
||||||
|
),
|
||||||
viewFields: viewFieldsByViewId.get(view.id) ?? [],
|
viewFields: viewFieldsByViewId.get(view.id) ?? [],
|
||||||
viewFilters: viewFiltersByViewId.get(view.id) ?? [],
|
viewFilters: viewFiltersByViewId.get(view.id) ?? [],
|
||||||
viewSorts: viewSortsByViewId.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/**',
|
'**/testing/cache/**',
|
||||||
'**/*.test.{ts,tsx}',
|
'**/*.test.{ts,tsx}',
|
||||||
'**/*.spec.{ts,tsx}',
|
'**/*.spec.{ts,tsx}',
|
||||||
'**/*.stories.{ts,tsx}',
|
|
||||||
'**/__stories__/**',
|
|
||||||
'**/__tests__/**',
|
'**/__tests__/**',
|
||||||
'**/__mocks__/**',
|
'**/__mocks__/**',
|
||||||
'**/types/**',
|
'**/types/**',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue