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:
@@ -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;
|
||||
|
||||
+75
@@ -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>
|
||||
|
||||
+18
@@ -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>
|
||||
);
|
||||
};
|
||||
-72
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
};
|
||||
+74
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+247
@@ -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} />;
|
||||
|
||||
+77
@@ -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>
|
||||
);
|
||||
};
|
||||
+35
-21
@@ -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>
|
||||
);
|
||||
|
||||
+18
-19
@@ -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>
|
||||
|
||||
|
||||
+16
-11
@@ -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>
|
||||
|
||||
+37
-17
@@ -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>
|
||||
);
|
||||
|
||||
+7
-2
@@ -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
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const NAVIGATION_DRAWER_CLICK_OUTSIDE_ID = 'navigation-drawer';
|
||||
+12
@@ -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);
|
||||
|
||||
+49
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -55,6 +55,6 @@ export class AgentMessageEntity {
|
||||
@OneToMany(() => AgentMessagePartEntity, (part) => part.message)
|
||||
parts: Relation<AgentMessagePartEntity[]>;
|
||||
|
||||
@CreateDateColumn()
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
+1
-1
@@ -39,6 +39,6 @@ export class AgentTurnEntity {
|
||||
@OneToMany(() => AgentTurnEvaluationEntity, (evaluation) => evaluation.turn)
|
||||
evaluations: Relation<AgentTurnEvaluationEntity[]>;
|
||||
|
||||
@CreateDateColumn()
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
+1
-1
@@ -32,6 +32,6 @@ export class AgentTurnEvaluationEntity {
|
||||
@Column({ type: 'text', nullable: true })
|
||||
comment: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
+2
-2
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user