Navbar with AI chats (#18161)

## Summary
Add Home/Chat tabs and a dedicated threads list in the navigation
drawer.

## Changes
- **Navbar tabs:** Tabs in the drawer to switch between Home and Chat
(with “New chat” button). Shown on desktop when expanded and on mobile
below the workspace selector.
- **Navbar threads list:** New `NavigationDrawerAIChatThreadsList` for
the Chat tab with date groups (Today / Yesterday / Older), thread rows
as `NavigationDrawerItem` (IconComment, title, timestamp). Shared
`useAIChatThreadClick` hook used by navbar and command menu; navbar
passes `resetNavigationStack: true`.
- **NavigationDrawerItem:** New `alwaysShowRightOptions` prop so the
timestamp is always visible (no hover-only).

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Abdul Rahman
2026-02-28 04:07:14 +05:30
committed by GitHub
parent 9f5a8735c9
commit e806d36099
34 changed files with 970 additions and 291 deletions
@@ -1,13 +1,7 @@
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState';
import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useAIChatThreadClick } from '@/ai/hooks/useAIChatThreadClick';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconSparkles } from 'twenty-ui/display';
import { type AgentChatThread } from '~/generated-metadata/graphql';
@@ -24,7 +18,7 @@ const StyledDateGroup = styled.div`
const StyledDateHeader = styled.div`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: 600;
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-bottom: ${({ theme }) => theme.spacing(1)};
`;
@@ -79,39 +73,7 @@ export const AIChatThreadGroup = ({
}) => {
const { t } = useLingui();
const theme = useTheme();
const [, setCurrentAIChatThread] = useAtomState(currentAIChatThreadState);
const setCurrentAIChatThreadTitle = useSetAtomState(
currentAIChatThreadTitleState,
);
const setAgentChatUsage = useSetAtomState(agentChatUsageState);
const { openAskAIPage } = useOpenAskAIPageInCommandMenu();
const handleThreadClick = (thread: AgentChatThread) => {
setCurrentAIChatThread(thread.id);
setCurrentAIChatThreadTitle(thread.title ?? null);
const hasUsageData =
(thread.conversationSize ?? 0) > 0 &&
isDefined(thread.contextWindowTokens);
setAgentChatUsage(
hasUsageData
? {
lastMessage: null,
conversationSize: thread.conversationSize ?? 0,
contextWindowTokens: thread.contextWindowTokens ?? 0,
inputTokens: thread.totalInputTokens,
outputTokens: thread.totalOutputTokens,
inputCredits: thread.totalInputCredits,
outputCredits: thread.totalOutputCredits,
}
: null,
);
openAskAIPage({
resetNavigationStack: false,
});
};
const { handleThreadClick } = useAIChatThreadClick();
if (threads.length === 0) {
return null;
@@ -0,0 +1,75 @@
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { IconComment } from 'twenty-ui/display';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { type AgentChatThread } from '~/generated-metadata/graphql';
import { beautifyPastDateRelativeToNowShort } from '~/utils/date-utils';
const StyledDateSection = styled.section`
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
const StyledThreadList = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(0.5)};
`;
const StyledDateHeader = styled.div`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-bottom: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(0, 2)};
`;
const StyledThreadTimestamp = styled.span`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.regular};
padding-right: ${({ theme }) => theme.spacing(0.5)};
`;
export type NavigationDrawerAIChatThreadDateSectionProps = {
title: string;
threads: AgentChatThread[];
currentThreadId: string | null;
onThreadClick: (thread: AgentChatThread) => void;
};
export const NavigationDrawerAIChatThreadDateSection = ({
title,
threads,
currentThreadId,
onThreadClick,
}: NavigationDrawerAIChatThreadDateSectionProps) => {
const { t } = useLingui();
return (
<StyledDateSection>
<StyledDateHeader>{title}</StyledDateHeader>
<StyledThreadList>
{threads.map((thread) => {
const isActive = currentThreadId === thread.id;
const timestamp = beautifyPastDateRelativeToNowShort(
thread.updatedAt ?? thread.createdAt,
);
return (
<NavigationDrawerItem
key={thread.id}
label={thread.title || t`New chat`}
Icon={IconComment}
active={isActive}
onClick={() => onThreadClick(thread)}
alwaysShowRightOptions
rightOptions={
<StyledThreadTimestamp>{timestamp}</StyledThreadTimestamp>
}
/>
);
})}
</StyledThreadList>
</StyledDateSection>
);
};
@@ -0,0 +1,55 @@
import styled from '@emotion/styled';
import { NavigationDrawerAIChatThreadDateSection } from '@/ai/components/NavigationDrawerAIChatThreadDateSection';
import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader';
import { useAIChatThreadClick } from '@/ai/hooks/useAIChatThreadClick';
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
import { groupThreadsByDate } from '@/ai/utils/groupThreadsByDate';
import { type DateGroupKey } from '@/ai/utils/dateGroupKey';
import { DATE_GROUP_KEYS } from '@/ai/utils/dateGroupKeys';
import { getDateGroupTitle } from '@/ai/utils/getDateGroupTitle';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useGetChatThreadsQuery } from '~/generated-metadata/graphql';
const StyledScrollableList = styled.div`
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
padding: ${({ theme }) => theme.spacing(2, 0)};
width: ${({ theme }) => `calc(100% - ${theme.spacing(2)})`};
`;
export const NavigationDrawerAIChatThreadsList = () => {
const currentAIChatThread = useAtomStateValue(currentAIChatThreadState);
const { handleThreadClick } = useAIChatThreadClick({
resetNavigationStack: true,
});
const { data: { chatThreads = [] } = {}, loading } = useGetChatThreadsQuery();
const groupedThreads = groupThreadsByDate(chatThreads);
if (loading === true) {
return <AIChatSkeletonLoader />;
}
return (
<StyledScrollableList>
{DATE_GROUP_KEYS.map((key: DateGroupKey) => {
const threads = groupedThreads[key];
if (threads.length === 0) return null;
return (
<NavigationDrawerAIChatThreadDateSection
key={key}
title={getDateGroupTitle(key)}
threads={threads}
currentThreadId={currentAIChatThread}
onThreadClick={handleThreadClick}
/>
);
})}
</StyledScrollableList>
);
};
@@ -0,0 +1,53 @@
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState';
import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { isDefined } from 'twenty-shared/utils';
import { type AgentChatThread } from '~/generated-metadata/graphql';
export type UseAIChatThreadClickOptions = {
resetNavigationStack?: boolean;
};
export const useAIChatThreadClick = (
options: UseAIChatThreadClickOptions = {},
) => {
const { resetNavigationStack = false } = options;
const [, setCurrentAIChatThread] = useAtomState(currentAIChatThreadState);
const setCurrentAIChatThreadTitle = useSetAtomState(
currentAIChatThreadTitleState,
);
const setAgentChatUsage = useSetAtomState(agentChatUsageState);
const { openAskAIPage } = useOpenAskAIPageInCommandMenu();
const handleThreadClick = (thread: AgentChatThread) => {
setCurrentAIChatThread(thread.id);
setCurrentAIChatThreadTitle(thread.title ?? null);
const hasUsageData =
(thread.conversationSize ?? 0) > 0 &&
isDefined(thread.contextWindowTokens);
setAgentChatUsage(
hasUsageData
? {
lastMessage: null,
conversationSize: thread.conversationSize ?? 0,
contextWindowTokens: thread.contextWindowTokens ?? 0,
inputTokens: thread.totalInputTokens,
outputTokens: thread.totalOutputTokens,
inputCredits: thread.totalInputCredits,
outputCredits: thread.totalOutputCredits,
}
: null,
);
openAskAIPage({
resetNavigationStack,
});
};
return { handleThreadClick };
};
@@ -2,9 +2,15 @@ import { type AgentChatThread } from '~/generated-metadata/graphql';
import { groupThreadsByDate } from '@/ai/utils/groupThreadsByDate';
describe('groupThreadsByDate', () => {
const baseThread: Omit<AgentChatThread, 'createdAt' | 'id'> = {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
const twoDaysAgo = new Date(today);
twoDaysAgo.setDate(today.getDate() - 2);
const baseThread: Omit<AgentChatThread, 'updatedAt' | 'id'> = {
title: 'Test Thread',
updatedAt: new Date().toISOString(),
createdAt: twoDaysAgo.toISOString(),
totalInputTokens: 0,
totalOutputTokens: 0,
contextWindowTokens: null,
@@ -13,16 +19,10 @@ describe('groupThreadsByDate', () => {
totalOutputCredits: 0,
};
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
const twoDaysAgo = new Date(today);
twoDaysAgo.setDate(today.getDate() - 2);
const threads: AgentChatThread[] = [
{ ...baseThread, id: '1', createdAt: today.toISOString() },
{ ...baseThread, id: '2', createdAt: yesterday.toISOString() },
{ ...baseThread, id: '3', createdAt: twoDaysAgo.toISOString() },
{ ...baseThread, id: '1', updatedAt: today.toISOString() },
{ ...baseThread, id: '2', updatedAt: yesterday.toISOString() },
{ ...baseThread, id: '3', updatedAt: twoDaysAgo.toISOString() },
];
it('groups threads into today, yesterday, and older', () => {
@@ -0,0 +1 @@
export type DateGroupKey = 'today' | 'yesterday' | 'older';
@@ -0,0 +1,7 @@
import { type DateGroupKey } from '@/ai/utils/dateGroupKey';
export const DATE_GROUP_KEYS: readonly DateGroupKey[] = [
'today',
'yesterday',
'older',
];
@@ -0,0 +1,14 @@
import { t } from '@lingui/core/macro';
import { type DateGroupKey } from '@/ai/utils/dateGroupKey';
export const getDateGroupTitle = (key: DateGroupKey): string => {
switch (key) {
case 'today':
return t`Today`;
case 'yesterday':
return t`Yesterday`;
case 'older':
return t`Older`;
}
};
@@ -1,17 +1,17 @@
import { type AgentChatThread } from '~/generated-metadata/graphql';
export const groupThreadsByDate = (threads: AgentChatThread[]) => {
import { type DateGroupKey } from '@/ai/utils/dateGroupKey';
export const groupThreadsByDate = (
threads: AgentChatThread[],
): Record<DateGroupKey, AgentChatThread[]> => {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
return threads.reduce<{
today: AgentChatThread[];
yesterday: AgentChatThread[];
older: AgentChatThread[];
}>(
return threads.reduce<Record<DateGroupKey, AgentChatThread[]>>(
(acc, thread) => {
const threadDate = new Date(thread.createdAt);
const threadDate = new Date(thread.updatedAt);
const threadDateString = threadDate.toDateString();
if (threadDateString === today.toDateString()) {
@@ -8,6 +8,7 @@ import { RECORD_CHIP_CLICK_OUTSIDE_ID } from '@/object-record/record-table/const
import { MENTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/ui/input/constants/MentionMenuDropdownClickOutsideId';
import { SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/ui/input/constants/SlashMenuDropdownClickOutsideId';
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { NAVIGATION_DRAWER_CLICK_OUTSIDE_ID } from '@/ui/navigation/navigation-drawer/constants/NavigationDrawerClickOutsideId';
import { PAGE_HEADER_COMMAND_MENU_BUTTON_CLICK_OUTSIDE_ID } from '@/ui/layout/page-header/constants/PageHeaderCommandMenuButtonClickOutsideId';
import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
@@ -76,6 +77,7 @@ export const CommandMenuOpenContainer = ({
callback: handleClickOutside,
listenerId: 'COMMAND_MENU_LISTENER_ID',
excludedClickOutsideIds: [
NAVIGATION_DRAWER_CLICK_OUTSIDE_ID,
PAGE_HEADER_COMMAND_MENU_BUTTON_CLICK_OUTSIDE_ID,
LINK_CHIP_CLICK_OUTSIDE_ID,
RECORD_CHIP_CLICK_OUTSIDE_ID,
@@ -1,51 +1,19 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import styled from '@emotion/styled';
import { useFavoritesByFolder } from '@/favorites/hooks/useFavoritesByFolder';
import { NavigationMenuItemFolderContentDispatcherEffect } from '@/navigation-menu-item/components/NavigationMenuItemFolderContentDispatcher';
import { useNavigationMenuItemsByFolder } from '@/navigation-menu-item/hooks/useNavigationMenuItemsByFolder';
import { MainNavigationDrawerFixedItems } from '@/navigation/components/MainNavigationDrawerFixedItems';
import { MainNavigationDrawerScrollableItems } from '@/navigation/components/MainNavigationDrawerScrollableItems';
import { MainNavigationDrawerAIChatContent } from '@/navigation/components/MainNavigationDrawerAIChatContent';
import { MainNavigationDrawerNavigationContent } from '@/navigation/components/MainNavigationDrawerNavigationContent';
import { MainNavigationDrawerTabsRow } from '@/navigation/components/MainNavigationDrawerTabsRow';
import { NavigationDrawer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawer';
import { NavigationDrawerFixedContent } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerFixedContent';
import { NavigationDrawerScrollableContent } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerScrollableContent';
import { currentFavoriteFolderIdState } from '@/ui/navigation/navigation-drawer/states/currentFavoriteFolderIdState';
import { currentNavigationMenuItemFolderIdState } from '@/ui/navigation/navigation-drawer/states/currentNavigationMenuItemFolderIdState';
import { navigationDrawerActiveTabState } from '@/ui/navigation/states/navigationDrawerActiveTabState';
import { NAVIGATION_DRAWER_TABS } from '@/ui/navigation/states/navigationDrawerTabs';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
const StyledScrollableContent = styled.div`
height: 100%;
min-height: 0;
`;
export const MainNavigationDrawer = ({ className }: { className?: string }) => {
const navigationDrawerActiveTab = useAtomStateValue(
navigationDrawerActiveTabState,
);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const currentFavoriteFolderId = useAtomStateValue(
currentFavoriteFolderIdState,
);
const currentNavigationMenuItemFolderId = useAtomStateValue(
currentNavigationMenuItemFolderIdState,
);
const { favoritesByFolder } = useFavoritesByFolder();
const { navigationMenuItemsByFolder } = useNavigationMenuItemsByFolder();
const isNavigationMenuItemEditingEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED,
);
const openedFavoriteFolder = favoritesByFolder.find(
(f) => f.folderId === currentFavoriteFolderId,
);
const openedNavigationMenuItemFolder = navigationMenuItemsByFolder.find(
(f) => f.id === currentNavigationMenuItemFolderId,
);
const openedFolder = isNavigationMenuItemEditingEnabled
? openedNavigationMenuItemFolder
: openedFavoriteFolder;
const openedFolderId = openedNavigationMenuItemFolder?.id ?? '';
return (
<NavigationDrawer
@@ -53,36 +21,15 @@ export const MainNavigationDrawer = ({ className }: { className?: string }) => {
title={currentWorkspace?.displayName ?? ''}
>
<NavigationDrawerFixedContent>
<MainNavigationDrawerFixedItems />
<MainNavigationDrawerTabsRow />
</NavigationDrawerFixedContent>
<NavigationDrawerScrollableContent>
{isNavigationMenuItemEditingEnabled ? (
<StyledScrollableContent>
{openedFolder ? (
<NavigationMenuItemFolderContentDispatcherEffect
folderName={openedFolder.folderName}
folderId={openedFolderId}
favorites={openedFavoriteFolder?.favorites}
navigationMenuItems={
openedNavigationMenuItemFolder?.navigationMenuItems
}
/>
) : (
<MainNavigationDrawerScrollableItems />
)}
</StyledScrollableContent>
) : openedFolder ? (
<NavigationMenuItemFolderContentDispatcherEffect
folderName={openedFolder.folderName}
folderId={openedFolderId}
favorites={openedFavoriteFolder?.favorites}
navigationMenuItems={
openedNavigationMenuItemFolder?.navigationMenuItems
}
/>
{navigationDrawerActiveTab ===
NAVIGATION_DRAWER_TABS.AI_CHAT_HISTORY ? (
<MainNavigationDrawerAIChatContent />
) : (
<MainNavigationDrawerScrollableItems />
<MainNavigationDrawerNavigationContent />
)}
</NavigationDrawerScrollableContent>
</NavigationDrawer>
@@ -0,0 +1,18 @@
import styled from '@emotion/styled';
import { NavigationDrawerAIChatThreadsList } from '@/ai/components/NavigationDrawerAIChatThreadsList';
const StyledAIChatThreadsListWrapper = styled.div`
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
`;
export const MainNavigationDrawerAIChatContent = () => {
return (
<StyledAIChatThreadsListWrapper>
<NavigationDrawerAIChatThreadsList />
</StyledAIChatThreadsListWrapper>
);
};
@@ -1,72 +0,0 @@
import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu';
import { useOpenRecordsSearchPageInCommandMenu } from '@/command-menu/hooks/useOpenRecordsSearchPageInCommandMenu';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { navigationDrawerExpandedMemorizedState } from '@/ui/navigation/states/navigationDrawerExpandedMemorizedState';
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useLingui } from '@lingui/react/macro';
import { useLocation, useNavigate } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconSearch, IconSettings, IconSparkles } from 'twenty-ui/display';
import { useIsMobile } from 'twenty-ui/utilities';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const MainNavigationDrawerFixedItems = () => {
const isMobile = useIsMobile();
const location = useLocation();
const setNavigationMemorizedUrl = useSetAtomState(
navigationMemorizedUrlState,
);
const [isNavigationDrawerExpanded, setIsNavigationDrawerExpanded] =
useAtomState(isNavigationDrawerExpandedState);
const setNavigationDrawerExpandedMemorized = useSetAtomState(
navigationDrawerExpandedMemorizedState,
);
const navigate = useNavigate();
const { t } = useLingui();
const { openRecordsSearchPage } = useOpenRecordsSearchPageInCommandMenu();
const { openAskAIPage } = useOpenAskAIPageInCommandMenu();
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
return (
!isMobile && (
<>
<NavigationDrawerItem
label={t`Search`}
Icon={IconSearch}
onClick={openRecordsSearchPage}
keyboard={['/']}
mouseUpNavigation={true}
/>
{isAiEnabled && (
<NavigationDrawerItem
label={t`Ask AI`}
Icon={IconSparkles}
onClick={() => openAskAIPage({ resetNavigationStack: true })}
keyboard={['@']}
mouseUpNavigation={true}
/>
)}
<NavigationDrawerItem
label={t`Settings`}
to={getSettingsPath(SettingsPath.ProfilePage)}
onClick={() => {
setNavigationDrawerExpandedMemorized(isNavigationDrawerExpanded);
setIsNavigationDrawerExpanded(true);
setNavigationMemorizedUrl(location.pathname + location.search);
navigate(getSettingsPath(SettingsPath.ProfilePage));
}}
Icon={IconSettings}
/>
</>
)
);
};
@@ -0,0 +1,74 @@
import styled from '@emotion/styled';
import { useFavoritesByFolder } from '@/favorites/hooks/useFavoritesByFolder';
import { NavigationMenuItemFolderContentDispatcherEffect } from '@/navigation-menu-item/components/NavigationMenuItemFolderContentDispatcher';
import { useNavigationMenuItemsByFolder } from '@/navigation-menu-item/hooks/useNavigationMenuItemsByFolder';
import { MainNavigationDrawerScrollableItems } from '@/navigation/components/MainNavigationDrawerScrollableItems';
import { currentFavoriteFolderIdState } from '@/ui/navigation/navigation-drawer/states/currentFavoriteFolderIdState';
import { currentNavigationMenuItemFolderIdState } from '@/ui/navigation/navigation-drawer/states/currentNavigationMenuItemFolderIdState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
const StyledScrollableContent = styled.div`
height: 100%;
min-height: 0;
`;
export const MainNavigationDrawerNavigationContent = () => {
const currentFavoriteFolderId = useAtomStateValue(
currentFavoriteFolderIdState,
);
const currentNavigationMenuItemFolderId = useAtomStateValue(
currentNavigationMenuItemFolderIdState,
);
const { favoritesByFolder } = useFavoritesByFolder();
const { navigationMenuItemsByFolder } = useNavigationMenuItemsByFolder();
const isNavigationMenuItemEditingEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED,
);
const openedFavoriteFolder = favoritesByFolder.find(
(folder) => folder.folderId === currentFavoriteFolderId,
);
const openedNavigationMenuItemFolder = navigationMenuItemsByFolder.find(
(folder) => folder.id === currentNavigationMenuItemFolderId,
);
const openedFolder = isNavigationMenuItemEditingEnabled
? openedNavigationMenuItemFolder
: openedFavoriteFolder;
const openedFolderId = openedNavigationMenuItemFolder?.id ?? '';
if (isNavigationMenuItemEditingEnabled) {
return (
<StyledScrollableContent>
{openedFolder ? (
<NavigationMenuItemFolderContentDispatcherEffect
folderName={openedFolder.folderName}
folderId={openedFolderId}
favorites={openedFavoriteFolder?.favorites}
navigationMenuItems={
openedNavigationMenuItemFolder?.navigationMenuItems
}
/>
) : (
<MainNavigationDrawerScrollableItems />
)}
</StyledScrollableContent>
);
}
return openedFolder ? (
<NavigationMenuItemFolderContentDispatcherEffect
folderName={openedFolder.folderName}
folderId={openedFolderId}
favorites={openedFavoriteFolder?.favorites}
navigationMenuItems={openedNavigationMenuItemFolder?.navigationMenuItems}
/>
) : (
<MainNavigationDrawerScrollableItems />
);
};
@@ -3,6 +3,8 @@ import { RemoteNavigationDrawerSection } from '@/object-metadata/components/Remo
import styled from '@emotion/styled';
import { lazy, Suspense } from 'react';
import { NavigationDrawerOtherSection } from '@/navigation/components/NavigationDrawerOtherSection';
const CurrentWorkspaceMemberNavigationMenuItemFoldersDispatcher = lazy(() =>
import(
'@/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItemFoldersDispatcher'
@@ -36,6 +38,7 @@ export const MainNavigationDrawerScrollableItems = () => {
<WorkspaceNavigationMenuItemsDispatcher />
</Suspense>
<RemoteNavigationDrawerSection />
<NavigationDrawerOtherSection />
</StyledScrollableItemsContainer>
);
};
@@ -0,0 +1,247 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import {
IconComment,
IconHome,
IconMessageCirclePlus,
} from 'twenty-ui/display';
import { useIsMobile } from 'twenty-ui/utilities';
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { navigationDrawerActiveTabState } from '@/ui/navigation/states/navigationDrawerActiveTabState';
import {
type NavigationDrawerActiveTab,
NAVIGATION_DRAWER_TABS,
} from '@/ui/navigation/states/navigationDrawerTabs';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
const StyledRow = styled.div<{ isExpanded: boolean }>`
align-items: center;
display: flex;
justify-content: ${({ isExpanded }) =>
isExpanded ? 'space-between' : 'center'};
gap: ${({ theme, isExpanded }) => (isExpanded ? theme.spacing(2) : 0)};
width: 100%;
transition: gap ${({ theme }) => theme.animation.duration.normal}s ease;
`;
const StyledTabsPill = styled.div`
align-items: center;
border: 1px solid ${({ theme }) => theme.border.color.medium};
background: ${({ theme }) => theme.background.secondary};
border-radius: ${({ theme }) => theme.border.radius.pill};
padding: ${({ theme }) => theme.spacing(0.75)};
height: ${({ theme }) => theme.spacing(7)};
display: flex;
width: ${({ theme }) => theme.spacing(18)};
gap: ${({ theme }) => theme.spacing(0.5)};
box-sizing: border-box;
`;
const StyledTabWrapper = styled.div<{ isActive: boolean }>`
border-radius: ${({ theme }) => theme.border.radius.pill};
align-items: center;
background: ${({ theme, isActive }) =>
isActive ? theme.background.transparent.light : 'transparent'};
color: ${({ theme, isActive }) =>
isActive ? theme.font.color.primary : theme.font.color.tertiary};
cursor: pointer;
display: flex;
justify-content: center;
height: 100%;
flex: 1;
&:hover {
background: ${({ theme, isActive }) =>
isActive
? theme.background.transparent.light
: theme.background.transparent.lighter};
}
`;
const StyledTabIcon = styled.div`
align-items: center;
display: flex;
justify-content: center;
height: ${({ theme }) => theme.spacing(5)};
width: ${({ theme }) => theme.spacing(5)};
`;
const StyledNewChatButtonWrapper = styled.div<{ isExpanded: boolean }>`
align-items: center;
border: 1px solid ${({ theme }) => theme.border.color.medium};
background: ${({ theme }) => theme.background.secondary};
border-radius: ${({ theme }) => theme.border.radius.pill};
box-sizing: border-box;
display: flex;
height: ${({ theme, isExpanded }) =>
isExpanded ? theme.spacing(7) : theme.spacing(6)};
justify-content: center;
padding: ${({ theme, isExpanded }) =>
isExpanded ? theme.spacing(0.75) : theme.spacing(0.5)};
width: ${({ theme, isExpanded }) =>
isExpanded ? theme.spacing(25.75) : theme.spacing(6)};
transition:
height ${({ theme }) => theme.animation.duration.normal}s ease,
padding ${({ theme }) => theme.animation.duration.normal}s ease;
`;
const StyledNewChatButton = styled.div`
align-items: center;
justify-content: center;
display: flex;
cursor: pointer;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
gap: ${({ theme }) => theme.spacing(1)};
height: 100%;
width: 100%;
border-radius: inherit;
color: ${({ theme }) => theme.font.color.secondary};
transition:
background ${({ theme }) => theme.animation.duration.fast}s ease,
color ${({ theme }) => theme.animation.duration.fast}s ease;
&:hover {
background: ${({ theme }) => theme.background.transparent.light};
color: ${({ theme }) => theme.font.color.primary};
}
`;
export const MainNavigationDrawerTabsRow = () => {
const theme = useTheme();
const isMobile = useIsMobile();
const isNavigationDrawerExpanded = useAtomStateValue(
isNavigationDrawerExpandedState,
);
const [navigationDrawerActiveTab, setNavigationDrawerActiveTab] =
useAtomState(navigationDrawerActiveTabState);
const { createChatThread } = useCreateNewAIChatThread();
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
const setIsNavigationDrawerExpanded = useSetAtomState(
isNavigationDrawerExpandedState,
);
if (!isAiEnabled) {
return null;
}
const isExpanded = isNavigationDrawerExpanded || isMobile;
const handleTabClick = (tab: NavigationDrawerActiveTab) => () => {
setNavigationDrawerActiveTab(tab);
};
const handleTabKeyDown =
(tab: NavigationDrawerActiveTab) => (event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setNavigationDrawerActiveTab(tab);
}
};
const handleNewChatClick = () => {
if (isMobile) {
setIsNavigationDrawerExpanded(false);
}
createChatThread();
};
const handleNewChatKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleNewChatClick();
}
};
const getTabIconColor = (isActive: boolean) =>
isActive ? theme.font.color.primary : theme.font.color.tertiary;
return (
<StyledRow isExpanded={isExpanded}>
<NavigationDrawerAnimatedCollapseWrapper>
<StyledTabsPill role="tablist" aria-label={t`Navigation tabs`}>
<StyledTabWrapper
isActive={
navigationDrawerActiveTab ===
NAVIGATION_DRAWER_TABS.NAVIGATION_MENU
}
role="tab"
aria-selected={
navigationDrawerActiveTab ===
NAVIGATION_DRAWER_TABS.NAVIGATION_MENU
}
aria-label={t`Home`}
tabIndex={
navigationDrawerActiveTab ===
NAVIGATION_DRAWER_TABS.NAVIGATION_MENU
? 0
: -1
}
onClick={handleTabClick(NAVIGATION_DRAWER_TABS.NAVIGATION_MENU)}
onKeyDown={handleTabKeyDown(NAVIGATION_DRAWER_TABS.NAVIGATION_MENU)}
>
<StyledTabIcon>
<IconHome
size={theme.icon.size.sm}
color={getTabIconColor(
navigationDrawerActiveTab ===
NAVIGATION_DRAWER_TABS.NAVIGATION_MENU,
)}
/>
</StyledTabIcon>
</StyledTabWrapper>
<StyledTabWrapper
isActive={
navigationDrawerActiveTab ===
NAVIGATION_DRAWER_TABS.AI_CHAT_HISTORY
}
role="tab"
aria-selected={
navigationDrawerActiveTab ===
NAVIGATION_DRAWER_TABS.AI_CHAT_HISTORY
}
aria-label={t`Chat`}
tabIndex={
navigationDrawerActiveTab ===
NAVIGATION_DRAWER_TABS.AI_CHAT_HISTORY
? 0
: -1
}
onClick={handleTabClick(NAVIGATION_DRAWER_TABS.AI_CHAT_HISTORY)}
onKeyDown={handleTabKeyDown(NAVIGATION_DRAWER_TABS.AI_CHAT_HISTORY)}
>
<StyledTabIcon>
<IconComment
size={theme.icon.size.sm}
color={getTabIconColor(
navigationDrawerActiveTab ===
NAVIGATION_DRAWER_TABS.AI_CHAT_HISTORY,
)}
/>
</StyledTabIcon>
</StyledTabWrapper>
</StyledTabsPill>
</NavigationDrawerAnimatedCollapseWrapper>
<StyledNewChatButtonWrapper isExpanded={isExpanded}>
<StyledNewChatButton
role="button"
tabIndex={0}
aria-label={t`New chat`}
onClick={handleNewChatClick}
onKeyDown={handleNewChatKeyDown}
>
<IconMessageCirclePlus size={theme.icon.size.md} />
{isExpanded && t`New chat`}
</StyledNewChatButton>
</StyledNewChatButtonWrapper>
</StyledRow>
);
};
@@ -1,27 +1,29 @@
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useOpenRecordsSearchPageInCommandMenu } from '@/command-menu/hooks/useOpenRecordsSearchPageInCommandMenu';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath';
import { useOpenSettingsMenu } from '@/navigation/hooks/useOpenSettings';
import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage';
import { currentMobileNavigationDrawerState } from '@/navigation/states/currentMobileNavigationDrawerState';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useNavigate } from 'react-router-dom';
import {
type IconComponent,
IconList,
IconMessageCirclePlus,
IconSearch,
IconSettings,
} from 'twenty-ui/display';
import { NavigationBar } from 'twenty-ui/navigation';
import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage';
import { currentMobileNavigationDrawerState } from '@/navigation/states/currentMobileNavigationDrawerState';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
type NavigationBarItemName = 'main' | 'search' | 'tasks' | 'settings';
type NavigationBarItemName = 'main' | 'search' | 'newAIChat';
export const MobileNavigationBar = () => {
const navigate = useNavigate();
@@ -34,7 +36,8 @@ export const MobileNavigationBar = () => {
useAtomState(isNavigationDrawerExpandedState);
const [currentMobileNavigationDrawer, setCurrentMobileNavigationDrawer] =
useAtomState(currentMobileNavigationDrawerState);
const { openSettingsMenu } = useOpenSettingsMenu();
const { createChatThread } = useCreateNewAIChatThread();
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
const { alphaSortedActiveNonSystemObjectMetadataItems } =
useFilteredObjectMetadataItems();
@@ -47,9 +50,7 @@ export const MobileNavigationBar = () => {
? currentMobileNavigationDrawer
: isCommandMenuOpened
? 'search'
: isSettingsPage
? 'settings'
: 'main';
: 'main';
const items: {
name: NavigationBarItemName;
@@ -91,14 +92,19 @@ export const MobileNavigationBar = () => {
openRecordsSearchPage();
},
},
{
name: 'settings',
Icon: IconSettings,
onClick: () => {
closeCommandMenu();
openSettingsMenu();
},
},
...(isAiEnabled
? [
{
name: 'newAIChat' as const,
Icon: IconMessageCirclePlus,
onClick: () => {
setIsNavigationDrawerExpanded(false);
closeCommandMenu();
createChatThread();
},
},
]
: []),
];
return <NavigationBar activeItemName={activeItemName} items={items} />;
@@ -0,0 +1,77 @@
import { useLingui } from '@lingui/react/macro';
import { useLocation, useNavigate } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconHelpCircle, IconSettings } from 'twenty-ui/display';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { navigationDrawerExpandedMemorizedState } from '@/ui/navigation/states/navigationDrawerExpandedMemorizedState';
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection';
import { isNavigationSectionOpenFamilyState } from '@/ui/navigation/navigation-drawer/states/isNavigationSectionOpenFamilyState';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
export const NavigationDrawerOtherSection = () => {
const { t } = useLingui();
const location = useLocation();
const navigate = useNavigate();
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const [isNavigationDrawerExpanded, setIsNavigationDrawerExpanded] =
useAtomState(isNavigationDrawerExpandedState);
const setNavigationDrawerExpandedMemorized = useSetAtomState(
navigationDrawerExpandedMemorizedState,
);
const setNavigationMemorizedUrl = useSetAtomState(
navigationMemorizedUrlState,
);
const { toggleNavigationSection } = useNavigationSection('Other');
const isNavigationSectionOpen = useAtomFamilyStateValue(
isNavigationSectionOpenFamilyState,
'Other',
);
const handleSettingsClick = () => {
setNavigationDrawerExpandedMemorized(isNavigationDrawerExpanded);
setIsNavigationDrawerExpanded(true);
setNavigationMemorizedUrl(location.pathname + location.search);
navigate(getSettingsPath(SettingsPath.ProfilePage));
};
return (
<NavigationDrawerSection>
<NavigationDrawerAnimatedCollapseWrapper>
<NavigationDrawerSectionTitle
label={t`Other`}
onClick={toggleNavigationSection}
/>
</NavigationDrawerAnimatedCollapseWrapper>
{isNavigationSectionOpen && (
<>
<NavigationDrawerItem
label={t`Settings`}
Icon={IconSettings}
onClick={handleSettingsClick}
/>
<NavigationDrawerItem
label={t`Documentation`}
to={getDocumentationUrl({
locale: currentWorkspaceMember?.locale,
})}
Icon={IconHelpCircle}
/>
</>
)}
</NavigationDrawerSection>
);
};
@@ -2,13 +2,11 @@ import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/consta
import { useAuth } from '@/auth/hooks/useAuth';
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { countAvailableWorkspaces } from '@/auth/utils/availableWorkspacesUtils';
import { supportChatState } from '@/client-config/states/supportChatState';
import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { supportChatState } from '@/client-config/states/supportChatState';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
@@ -19,22 +17,27 @@ import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownM
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { MULTI_WORKSPACE_DROPDOWN_ID } from '@/ui/navigation/navigation-drawer/constants/MultiWorkspaceDropdownId';
import { multiWorkspaceDropdownState } from '@/ui/navigation/navigation-drawer/states/multiWorkspaceDropdownState';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { navigationDrawerExpandedMemorizedState } from '@/ui/navigation/states/navigationDrawerExpandedMemorizedState';
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
import { useColorScheme } from '@/ui/theme/hooks/useColorScheme';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { type ApolloError } from '@apollo/client';
import styled from '@emotion/styled';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isNonEmptyString } from '@sniptt/guards';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useLocation } from 'react-router-dom';
import { AppPath, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import {
Avatar,
IconDotsVertical,
IconHelpCircle,
IconLogout,
IconMessage,
IconPlus,
IconSettings,
IconSwitchHorizontal,
IconUserPlus,
} from 'twenty-ui/display';
@@ -57,7 +60,6 @@ const StyledDescription = styled.div`
export const MultiWorkspaceDropdownDefaultComponents = () => {
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const { t } = useLingui();
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const availableWorkspaces = useAtomStateValue(availableWorkspacesState);
@@ -79,19 +81,21 @@ export const MultiWorkspaceDropdownDefaultComponents = () => {
multiWorkspaceDropdownState,
);
const location = useLocation();
const [isNavigationDrawerExpanded, setIsNavigationDrawerExpanded] =
useAtomState(isNavigationDrawerExpandedState);
const setNavigationDrawerExpandedMemorized = useSetAtomState(
navigationDrawerExpandedMemorizedState,
);
const setNavigationMemorizedUrl = useSetAtomState(
navigationMemorizedUrlState,
);
const handleSupport = () => {
window.FrontChat?.('show');
closeDropdown(MULTI_WORKSPACE_DROPDOWN_ID);
};
const handleDocumentation = () => {
window.open(
getDocumentationUrl({ locale: currentWorkspaceMember?.locale }),
'_blank',
);
closeDropdown(MULTI_WORKSPACE_DROPDOWN_ID);
};
const handleChange = async (availableWorkspace: AvailableWorkspace) => {
redirectToWorkspaceDomain(
getWorkspaceUrl(availableWorkspace.workspaceUrls),
@@ -149,6 +153,11 @@ export const MultiWorkspaceDropdownDefaultComponents = () => {
text={t`Create Workspace`}
onClick={createWorkspace}
/>
<MenuItem
LeftIcon={IconLogout}
text={t`Log out`}
onClick={signOut}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
@@ -223,6 +232,17 @@ export const MultiWorkspaceDropdownDefaultComponents = () => {
>
<MenuItem LeftIcon={IconUserPlus} text={t`Invite user`} />
</UndecoratedLink>
<UndecoratedLink
to={getSettingsPath(SettingsPath.ProfilePage)}
onClick={() => {
setNavigationDrawerExpandedMemorized(isNavigationDrawerExpanded);
setIsNavigationDrawerExpanded(true);
setNavigationMemorizedUrl(location.pathname + location.search);
closeDropdown(MULTI_WORKSPACE_DROPDOWN_ID);
}}
>
<MenuItem LeftIcon={IconSettings} text={t`Settings`} />
</UndecoratedLink>
{isSupportChatConfigured && (
<MenuItem
LeftIcon={IconMessage}
@@ -230,12 +250,6 @@ export const MultiWorkspaceDropdownDefaultComponents = () => {
onClick={handleSupport}
/>
)}
<MenuItem
LeftIcon={IconHelpCircle}
text={t`Documentation`}
onClick={handleDocumentation}
/>
<MenuItem LeftIcon={IconLogout} text={t`Log out`} onClick={signOut} />
</DropdownMenuItemsContainer>
</DropdownContent>
);
@@ -3,19 +3,22 @@ import { type ReactNode, useState } from 'react';
import { useIsSettingsDrawer } from '@/navigation/hooks/useIsSettingsDrawer';
import { tableWidthResizeIsActiveState } from '@/object-record/record-table/states/tableWidthResizeIsActivedState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { ResizablePanelEdge } from '@/ui/layout/resizable-panel/components/ResizablePanelEdge';
import { NAVIGATION_DRAWER_COLLAPSED_WIDTH } from '@/ui/layout/resizable-panel/constants/NavigationDrawerCollapsedWidth';
import { NAVIGATION_DRAWER_CONSTRAINTS } from '@/ui/layout/resizable-panel/constants/NavigationDrawerConstraints';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
import { NavigationDrawerWidthEffect } from '@/ui/navigation/components/NavigationDrawerWidthEffect';
import { NAVIGATION_DRAWER_CLICK_OUTSIDE_ID } from '@/ui/navigation/navigation-drawer/constants/NavigationDrawerClickOutsideId';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { navigationDrawerActiveTabState } from '@/ui/navigation/states/navigationDrawerActiveTabState';
import { NAVIGATION_DRAWER_TABS } from '@/ui/navigation/states/navigationDrawerTabs';
import {
NAVIGATION_DRAWER_WIDTH_VAR,
navigationDrawerWidthState,
} from '@/ui/navigation/states/navigationDrawerWidthState';
import { NavigationDrawerWidthEffect } from '@/ui/navigation/components/NavigationDrawerWidthEffect';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
import { NavigationDrawerBackButton } from './NavigationDrawerBackButton';
import { NavigationDrawerHeader } from './NavigationDrawerHeader';
@@ -47,11 +50,13 @@ const StyledAnimatedContainer = styled.div<{
const StyledContainer = styled.div<{
isSettings?: boolean;
isMobile?: boolean;
isExpanded?: boolean;
}>`
box-sizing: border-box;
display: flex;
flex-direction: column;
width: var(${NAVIGATION_DRAWER_WIDTH_VAR});
width: ${({ isExpanded }) =>
isExpanded ? `var(${NAVIGATION_DRAWER_WIDTH_VAR})` : '100%'};
gap: ${({ theme }) => theme.spacing(3)};
height: 100%;
padding: ${({ theme, isSettings, isMobile }) =>
@@ -72,7 +77,6 @@ export const NavigationDrawer = ({
className,
title,
}: NavigationDrawerProps) => {
const [isHovered, setIsHovered] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const isMobile = useIsMobile();
const isSettingsDrawer = useIsSettingsDrawer();
@@ -82,20 +86,16 @@ export const NavigationDrawer = ({
const [navigationDrawerWidth, setNavigationDrawerWidth] = useAtomState(
navigationDrawerWidthState,
);
const setNavigationDrawerActiveTab = useSetAtomState(
navigationDrawerActiveTabState,
);
const setTableWidthResizeIsActive = useSetAtomState(
tableWidthResizeIsActiveState,
);
const handleHover = () => {
setIsHovered(true);
};
const handleMouseLeave = () => {
setIsHovered(false);
};
const handleCollapse = () => {
setIsNavigationDrawerExpanded(false);
setNavigationDrawerActiveTab(NAVIGATION_DRAWER_TABS.NAVIGATION_MENU);
setIsResizing(false);
setTableWidthResizeIsActive(true);
};
@@ -116,21 +116,20 @@ export const NavigationDrawer = ({
<NavigationDrawerWidthEffect />
<StyledAnimatedContainer
className={className}
data-click-outside-id={NAVIGATION_DRAWER_CLICK_OUTSIDE_ID}
isExpanded={isNavigationDrawerExpanded}
isResizing={isResizing}
>
<StyledContainer
isSettings={isSettingsDrawer}
isMobile={isMobile}
onMouseEnter={handleHover}
onMouseLeave={handleMouseLeave}
isExpanded={isNavigationDrawerExpanded}
>
{isSettingsDrawer && title ? (
!isMobile && <NavigationDrawerBackButton title={title} />
) : (
<NavigationDrawerHeader showCollapseButton={isHovered} />
<NavigationDrawerHeader showCollapseButton />
)}
{children}
</StyledContainer>
@@ -1,4 +1,7 @@
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { navigationDrawerActiveTabState } from '@/ui/navigation/states/navigationDrawerActiveTabState';
import { NAVIGATION_DRAWER_TABS } from '@/ui/navigation/states/navigationDrawerTabs';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import styled from '@emotion/styled';
import {
@@ -26,26 +29,28 @@ export const NavigationDrawerCollapseButton = ({
className,
direction = 'left',
}: NavigationDrawerCollapseButtonProps) => {
const setIsNavigationDrawerExpanded = useSetAtomState(
isNavigationDrawerExpandedState,
const [isNavigationDrawerExpanded, setIsNavigationDrawerExpanded] =
useAtomState(isNavigationDrawerExpandedState);
const setNavigationDrawerActiveTab = useSetAtomState(
navigationDrawerActiveTabState,
);
const handleClick = () => {
if (isNavigationDrawerExpanded) {
setNavigationDrawerActiveTab(NAVIGATION_DRAWER_TABS.NAVIGATION_MENU);
}
setIsNavigationDrawerExpanded((previousIsExpanded) => !previousIsExpanded);
};
return (
<StyledCollapseButton
className={className}
onClick={() =>
setIsNavigationDrawerExpanded(
(previousIsExpanded) => !previousIsExpanded,
)
}
>
<StyledCollapseButton className={className} onClick={handleClick}>
<LightIconButton
Icon={
direction === 'left'
? IconLayoutSidebarLeftCollapse
: IconLayoutSidebarRightCollapse
}
accent="tertiary"
accent="secondary"
size="small"
/>
</StyledCollapseButton>
@@ -1,30 +1,42 @@
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { IconSearch } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { MultiWorkspaceDropdownButton } from '@/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/MultiWorkspaceDropdownButton';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useOpenRecordsSearchPageInCommandMenu } from '@/command-menu/hooks/useOpenRecordsSearchPageInCommandMenu';
import { PAGE_BAR_MIN_HEIGHT } from '@/ui/layout/page/constants/PageBarMinHeight';
import { MultiWorkspaceDropdownButton } from '@/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdown/MultiWorkspaceDropdownButton';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { NavigationDrawerCollapseButton } from './NavigationDrawerCollapseButton';
const StyledContainer = styled.div`
const StyledContainer = styled.div<{ isExpanded: boolean }>`
align-items: center;
display: flex;
flex-direction: ${({ isExpanded }) => (isExpanded ? 'row' : 'column')};
gap: ${({ theme, isExpanded }) => (isExpanded ? 0 : theme.spacing(4))};
user-select: none;
padding-right: ${({ theme }) => theme.spacing(2)};
min-height: ${PAGE_BAR_MIN_HEIGHT}px;
transition: gap ${({ theme }) => theme.animation.duration.normal}s ease;
`;
const StyledRightActions = styled.div<{ isExpanded: boolean }>`
align-items: center;
display: flex;
flex-direction: ${({ isExpanded }) => (isExpanded ? 'row' : 'column')};
gap: ${({ theme, isExpanded }) => (isExpanded ? 0 : theme.spacing(1))};
margin-left: ${({ isExpanded }) => (isExpanded ? 'auto' : 0)};
transition: gap ${({ theme }) => theme.animation.duration.normal}s ease;
`;
const StyledNavigationDrawerCollapseButton = styled(
NavigationDrawerCollapseButton,
)<{ show?: boolean }>`
height: ${({ theme }) => theme.spacing(4)};
margin-left: auto;
opacity: ${({ show }) => (show ? 1 : 0)};
)`
height: ${({ theme }) => theme.spacing(6)};
padding-right: ${({ theme }) => theme.spacing(1)};
transition: opacity ${({ theme }) => theme.animation.duration.normal}s;
width: ${({ theme }) => theme.spacing(4)};
width: ${({ theme }) => theme.spacing(6)};
`;
type NavigationDrawerHeaderProps = {
@@ -35,19 +47,27 @@ export const NavigationDrawerHeader = ({
showCollapseButton,
}: NavigationDrawerHeaderProps) => {
const isMobile = useIsMobile();
const { openRecordsSearchPage } = useOpenRecordsSearchPageInCommandMenu();
const isNavigationDrawerExpanded = useAtomStateValue(
isNavigationDrawerExpandedState,
);
return (
<StyledContainer>
<StyledContainer isExpanded={isNavigationDrawerExpanded}>
<MultiWorkspaceDropdownButton />
{!isMobile && isNavigationDrawerExpanded && (
<StyledNavigationDrawerCollapseButton
direction="left"
show={showCollapseButton}
/>
{!isMobile && (
<StyledRightActions isExpanded={isNavigationDrawerExpanded}>
<LightIconButton
Icon={IconSearch}
accent="secondary"
size="small"
onClick={openRecordsSearchPage}
aria-label={t`Search`}
/>
{isNavigationDrawerExpanded && showCollapseButton && (
<StyledNavigationDrawerCollapseButton direction="left" />
)}
</StyledRightActions>
)}
</StyledContainer>
);
@@ -51,6 +51,7 @@ export type NavigationDrawerItemProps = {
count?: number;
keyboard?: string[];
rightOptions?: ReactNode;
alwaysShowRightOptions?: boolean;
isDragging?: boolean;
isRightOptionsDropdownOpen?: boolean;
triggerEvent?: TriggerEventType;
@@ -257,6 +258,7 @@ const visibleStateStyles = css`
const StyledRightOptionsVisbility = styled.div<{
isMobile: boolean;
isRightOptionsDropdownOpen?: boolean;
alwaysVisible?: boolean;
}>`
display: block;
opacity: 0;
@@ -269,8 +271,9 @@ const StyledRightOptionsVisbility = styled.div<{
height: 1px;
width: 1px;
${({ isMobile, isRightOptionsDropdownOpen }) =>
(isMobile || isRightOptionsDropdownOpen) && visibleStateStyles}
${({ isMobile, isRightOptionsDropdownOpen, alwaysVisible }) =>
(isMobile || isRightOptionsDropdownOpen || alwaysVisible) &&
visibleStateStyles}
.navigation-drawer-item:hover & {
${visibleStateStyles}
@@ -294,6 +297,7 @@ export const NavigationDrawerItem = ({
keyboard,
subItemState,
rightOptions,
alwaysShowRightOptions = false,
isDragging,
isRightOptionsDropdownOpen,
triggerEvent,
@@ -456,6 +460,7 @@ export const NavigationDrawerItem = ({
}}
>
<StyledRightOptionsVisbility
alwaysVisible={alwaysShowRightOptions}
isMobile={isMobile}
isRightOptionsDropdownOpen={
isRightOptionsDropdownOpen || false
@@ -0,0 +1 @@
export const NAVIGATION_DRAWER_CLICK_OUTSIDE_ID = 'navigation-drawer';
@@ -0,0 +1,12 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
import {
type NavigationDrawerActiveTab,
NAVIGATION_DRAWER_TABS,
} from '@/ui/navigation/states/navigationDrawerTabs';
export const navigationDrawerActiveTabState =
createAtomState<NavigationDrawerActiveTab>({
key: 'navigationDrawerActiveTab',
defaultValue: NAVIGATION_DRAWER_TABS.NAVIGATION_MENU,
});
@@ -0,0 +1,7 @@
export const NAVIGATION_DRAWER_TABS = {
NAVIGATION_MENU: 'home',
AI_CHAT_HISTORY: 'chat',
} as const;
export type NavigationDrawerActiveTab =
(typeof NAVIGATION_DRAWER_TABS)[keyof typeof NAVIGATION_DRAWER_TABS];
@@ -10,6 +10,7 @@ import {
beautifyExactDate,
beautifyExactDateTime,
beautifyPastDateRelativeToNow,
beautifyPastDateRelativeToNowShort,
hasDatePassed,
parseDate,
} from '~/utils/date-utils';
@@ -122,6 +123,66 @@ describe('beautifyPastDateRelativeToNow', () => {
});
});
describe('beautifyPastDateRelativeToNowShort', () => {
it('should return "now" for dates less than 60 seconds ago', () => {
const result = beautifyPastDateRelativeToNowShort(
'2023-12-31T23:59:15.000Z',
);
expect(result).toBe('now');
});
it('should return minutes format', () => {
const result = beautifyPastDateRelativeToNowShort(
'2023-12-31T23:55:00.000Z',
);
expect(result).toBe('5m');
});
it('should return hours format', () => {
const result = beautifyPastDateRelativeToNowShort(
'2023-12-31T22:00:00.000Z',
);
expect(result).toBe('2h');
});
it('should return days format', () => {
const result = beautifyPastDateRelativeToNowShort(
'2023-12-29T00:00:00.000Z',
);
expect(result).toBe('3d');
});
it('should return weeks format', () => {
const result = beautifyPastDateRelativeToNowShort(
'2023-12-18T00:00:00.000Z',
);
expect(result).toBe('2w');
});
it('should return months format', () => {
const result = beautifyPastDateRelativeToNowShort(
'2023-08-01T00:00:00.000Z',
);
expect(result).toBe('5mo');
});
it('should return years format', () => {
const result = beautifyPastDateRelativeToNowShort(
'2022-01-01T00:00:00.000Z',
);
expect(result).toBe('2y');
});
it('should return empty string and log error for invalid date', () => {
const result = beautifyPastDateRelativeToNowShort('invalid-date-string');
expect(logError).toHaveBeenCalledWith(
Error('Invalid date passed to formatPastDate: "invalid-date-string"'),
);
expect(result).toBe('');
});
});
describe('hasDatePassed', () => {
it('should log an error and return false when passed an invalid date string', () => {
const result = hasDatePassed('invalid-date-string');
@@ -115,6 +115,43 @@ export const beautifyPastDateRelativeToNow = (
}
};
export const beautifyPastDateRelativeToNowShort = (
pastDate: Date | string | number,
) => {
try {
const parsedDate = parseDate(pastDate);
const now = new Date();
const diffInSeconds = Math.abs(
(now.getTime() - parsedDate.getTime()) / 1000,
);
if (diffInSeconds < 60) return t`now`;
const diffInMinutes = Math.floor(diffInSeconds / 60);
if (diffInMinutes < 60) return `${diffInMinutes}m`;
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) return `${diffInHours}h`;
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 7) return `${diffInDays}d`;
const diffInWeeks = Math.floor(diffInDays / 7);
if (diffInWeeks < 5) return `${diffInWeeks}w`;
const diffInMonths = Math.floor(diffInDays / 30);
if (diffInMonths < 12) return `${diffInMonths}mo`;
const diffInYears = Math.floor(diffInDays / 365);
return `${diffInYears}y`;
} catch (error) {
logError(error);
return '';
}
};
export const hasDatePassed = (date: Date | string | number) => {
try {
const parsedDate = parseDate(date);
@@ -0,0 +1,49 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class FixAiEntityTimestampsToTimestamptz1771600000000
implements MigrationInterface
{
name = 'FixAiEntityTimestampsToTimestamptz1771600000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."agentChatThread" ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE USING "createdAt"::timestamptz`,
);
await queryRunner.query(
`ALTER TABLE "core"."agentChatThread" ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE USING "updatedAt"::timestamptz`,
);
await queryRunner.query(
`ALTER TABLE "core"."agentMessage" ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE USING "createdAt"::timestamptz`,
);
await queryRunner.query(
`ALTER TABLE "core"."agentMessagePart" ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE USING "createdAt"::timestamptz`,
);
await queryRunner.query(
`ALTER TABLE "core"."agentTurn" ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE USING "createdAt"::timestamptz`,
);
await queryRunner.query(
`ALTER TABLE "core"."agentTurnEvaluation" ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE USING "createdAt"::timestamptz`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."agentTurnEvaluation" ALTER COLUMN "createdAt" TYPE TIMESTAMP USING "createdAt"::timestamp`,
);
await queryRunner.query(
`ALTER TABLE "core"."agentTurn" ALTER COLUMN "createdAt" TYPE TIMESTAMP USING "createdAt"::timestamp`,
);
await queryRunner.query(
`ALTER TABLE "core"."agentMessagePart" ALTER COLUMN "createdAt" TYPE TIMESTAMP USING "createdAt"::timestamp`,
);
await queryRunner.query(
`ALTER TABLE "core"."agentMessage" ALTER COLUMN "createdAt" TYPE TIMESTAMP USING "createdAt"::timestamp`,
);
await queryRunner.query(
`ALTER TABLE "core"."agentChatThread" ALTER COLUMN "updatedAt" TYPE TIMESTAMP USING "updatedAt"::timestamp`,
);
await queryRunner.query(
`ALTER TABLE "core"."agentChatThread" ALTER COLUMN "createdAt" TYPE TIMESTAMP USING "createdAt"::timestamp`,
);
}
}
@@ -93,6 +93,6 @@ export class AgentMessagePartEntity {
@Column({ type: 'jsonb', nullable: true })
providerMetadata: Record<string, Record<string, JSONValue>> | null;
@CreateDateColumn()
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
}
@@ -55,6 +55,6 @@ export class AgentMessageEntity {
@OneToMany(() => AgentMessagePartEntity, (part) => part.message)
parts: Relation<AgentMessagePartEntity[]>;
@CreateDateColumn()
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
}
@@ -39,6 +39,6 @@ export class AgentTurnEntity {
@OneToMany(() => AgentTurnEvaluationEntity, (evaluation) => evaluation.turn)
evaluations: Relation<AgentTurnEvaluationEntity[]>;
@CreateDateColumn()
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
}
@@ -32,6 +32,6 @@ export class AgentTurnEvaluationEntity {
@Column({ type: 'text', nullable: true })
comment: string | null;
@CreateDateColumn()
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
}
@@ -57,9 +57,9 @@ export class AgentChatThreadEntity {
@OneToMany(() => AgentMessageEntity, (message) => message.thread)
messages: EntityRelation<AgentMessageEntity[]>;
@CreateDateColumn()
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn()
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}