Compare commits

...

6 Commits

Author SHA1 Message Date
canisminor1990 3aa8be4a13 💄 style: Update Sync Style 2024-05-05 00:30:19 +08:00
canisminor1990 4d573ab697 🔧 chore: Revert sync style 2024-05-05 00:27:35 +08:00
canisminor1990 f83b99197e 🔧 chore: Rename workspace 2024-05-04 18:12:49 +08:00
canisminor1990 6a2961dd4a 🐛 fix: Fix ci 2024-05-04 18:12:24 +08:00
canisminor1990 33ad7e490f test: Fix test 2024-05-04 18:12:13 +08:00
canisminor1990 dde83026b8 ♻️ refactor: Refactor Chat Layout 2024-05-04 18:11:43 +08:00
109 changed files with 2144 additions and 611 deletions
@@ -1,19 +0,0 @@
import { memo } from 'react';
import RawConversation from '@/features/Conversation';
import TelemetryNotification from '../../features/TelemetryNotification';
import ChatInput from './ChatInput';
import HotKeys from './HotKeys';
const Conversation = memo(() => {
return (
<>
<RawConversation chatInput={<ChatInput />} />
<HotKeys />
<TelemetryNotification />
</>
);
});
export default Conversation;
-22
View File
@@ -1,22 +0,0 @@
'use client';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import ClientResponsiveContent from '@/components/client/ClientResponsiveContent';
import ChatHeader from './features/ChatHeader';
import Conversation from './features/Conversation';
import SideBar from './features/SideBar';
const Desktop = memo(() => (
<>
<ChatHeader />
<Flexbox flex={1} height={'calc(100% - 64px)'} horizontal>
<Conversation />
<SideBar />
</Flexbox>
</>
));
export default ClientResponsiveContent({ Desktop, Mobile: () => import('../(mobile)') });
@@ -1,57 +0,0 @@
import { ActionIcon, Avatar, Logo, MobileNavBar } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { MessageSquarePlus } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { MOBILE_HEADER_ICON_SIZE } from '@/const/layoutTokens';
import SyncStatusInspector from '@/features/SyncStatusInspector';
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useSessionStore } from '@/store/session';
import { useUserStore } from '@/store/user';
import { userProfileSelectors } from '@/store/user/selectors';
import { mobileHeaderSticky } from '@/styles/mobileHeader';
export const useStyles = createStyles(({ css, token }) => ({
logo: css`
fill: ${token.colorText};
`,
top: css`
position: sticky;
top: 0;
`,
}));
const Header = memo(() => {
const [createSession] = useSessionStore((s) => [s.createSession]);
const router = useRouter();
const avatar = useUserStore(userProfileSelectors.userAvatar);
const { showCreateSession } = useServerConfigStore(featureFlagsSelectors);
return (
<MobileNavBar
left={
<Flexbox align={'center'} gap={8} horizontal style={{ marginLeft: 8 }}>
<div onClick={() => router.push('/me')}>
{avatar ? <Avatar avatar={avatar} size={28} /> : <Logo size={28} />}
</div>
<Logo type={'text'} />
<SyncStatusInspector placement={'bottom'} />
</Flexbox>
}
right={
showCreateSession && (
<ActionIcon
icon={MessageSquarePlus}
onClick={() => createSession()}
size={MOBILE_HEADER_ICON_SIZE}
/>
)
}
style={mobileHeaderSticky}
/>
);
});
export default Header;
@@ -1,17 +0,0 @@
import { memo } from 'react';
import SessionListContent from '../../features/SessionListContent';
import SessionSearchBar from '../../features/SessionSearchBar';
const Sessions = memo(() => {
return (
<>
<div style={{ padding: '8px 16px' }}>
<SessionSearchBar mobile />
</div>
<SessionListContent />
</>
);
});
export default Sessions;
-26
View File
@@ -1,26 +0,0 @@
'use client';
import { useRouter } from 'next/navigation';
import { memo, useEffect } from 'react';
import MobileContentLayout from '@/components/server/MobileNavLayout';
import SessionHeader from './features/SessionHeader';
import SessionList from './features/SessionList';
const ChatMobilePage = memo(() => {
const router = useRouter();
useEffect(() => {
router.prefetch('/chat/mobile');
router.prefetch('/settings');
}, []);
return (
<MobileContentLayout header={<SessionHeader />} withNav>
<SessionList />
</MobileContentLayout>
);
});
export default ChatMobilePage;
@@ -1,26 +0,0 @@
'use client';
import dynamic from 'next/dynamic';
import { memo } from 'react';
import MobileContentLayout from '@/components/server/MobileNavLayout';
import Conversation from '@/features/Conversation';
import SessionHydration from '../../components/SessionHydration';
import TelemetryNotification from '../../features/TelemetryNotification';
import ChatInput from '../features/ChatInput';
import ChatHeader from './ChatHeader';
const TopicList = dynamic(() => import('../features/TopicList'));
const Chat = memo(() => {
return (
<MobileContentLayout header={<ChatHeader />}>
<Conversation chatInput={<ChatInput />} mobile />
<TopicList />
<TelemetryNotification mobile />
<SessionHydration />
</MobileContentLayout>
);
});
export default Chat;
@@ -0,0 +1,16 @@
import Conversation from '@/features/Conversation';
import { isMobileDevice } from '@/utils/responsive';
import DesktopChatInput from './features/ChatInput/Desktop';
import MobileChatInput from './features/ChatInput/Mobile';
const ChatConversation = () => {
const mobile = isMobileDevice();
const ChatInput = mobile ? MobileChatInput : DesktopChatInput;
return <Conversation chatInput={<ChatInput />} mobile={mobile} />;
};
ChatConversation.displayName = 'ChatConversation';
export default ChatConversation;
@@ -1,3 +1,5 @@
'use client';
import { DraggablePanel } from '@lobehub/ui';
import { memo, useState } from 'react';
import { Flexbox } from 'react-layout-kit';
@@ -13,7 +15,7 @@ import Footer from './Footer';
import Head from './Header';
import TextArea from './TextArea';
const ChatInput = memo(() => {
const DesktopChatInput = memo(() => {
const [expand, setExpand] = useState<boolean>(false);
const [inputHeight, updatePreference] = useGlobalStore((s) => [
@@ -52,4 +54,6 @@ const ChatInput = memo(() => {
);
});
export default ChatInput;
DesktopChatInput.displayName = 'DesktopChatInput';
export default DesktopChatInput;
@@ -1,3 +1,5 @@
'use client';
import { MobileChatInputArea, MobileChatSendButton } from '@lobehub/ui';
import { useTheme } from 'antd-style';
import { memo } from 'react';
@@ -10,7 +12,7 @@ import { useChatInput } from '@/features/ChatInput/useChatInput';
import Files from './Files';
const ChatInputMobileLayout = memo(() => {
const MobileChatInput = memo(() => {
const { t } = useTranslation('chat');
const theme = useTheme();
const { ref, onSend, loading, value, onInput, onStop, expand, setExpand } = useChatInput();
@@ -27,6 +29,7 @@ const ChatInputMobileLayout = memo(() => {
setExpand={setExpand}
style={{
background: `linear-gradient(to bottom, ${theme.colorFillQuaternary}, transparent)`,
top: expand ? 0 : undefined,
width: '100%',
}}
textAreaLeftAddons={<STT mobile />}
@@ -44,4 +47,6 @@ const ChatInputMobileLayout = memo(() => {
);
});
export default ChatInputMobileLayout;
MobileChatInput.displayName = 'MobileChatInput';
export default MobileChatInput;
@@ -0,0 +1,19 @@
import { isMobileDevice } from '@/utils/responsive';
import SystemRole from './features/SystemRole';
import TopicListContent from './features/TopicListContent';
const Topic = () => {
const mobile = isMobileDevice();
return (
<>
{!mobile && <SystemRole />}
<TopicListContent mobile={mobile} />
</>
);
};
Topic.displayName = 'ChatTopic';
export default Topic;
@@ -0,0 +1,39 @@
import { createStyles } from 'antd-style';
import { type ReactNode, memo } from 'react';
import { Flexbox } from 'react-layout-kit';
const useStyles = createStyles(({ css, token }) => ({
header: css`
z-index: 10;
box-shadow: 0 2px 6px ${token.colorBgLayout};
`,
}));
interface SidebarHeaderProps {
actions?: ReactNode;
title: ReactNode;
}
const SidebarHeader = memo<SidebarHeaderProps>(({ title, actions }) => {
const { styles } = useStyles();
return (
<Flexbox
align={'center'}
className={styles.header}
distribution={'space-between'}
horizontal
padding={14}
paddingInline={16}
>
<Flexbox align={'center'} gap={4} horizontal>
{title}
</Flexbox>
<Flexbox align={'center'} gap={2} horizontal>
{actions}
</Flexbox>
</Flexbox>
);
});
export default SidebarHeader;
@@ -1,3 +1,5 @@
'use client';
import { ActionIcon, EditableMessage } from '@lobehub/ui';
import { Skeleton } from 'antd';
import { Edit } from 'lucide-react';
@@ -21,7 +23,7 @@ import { useStyles } from './style';
const SystemRole = memo(() => {
const [editing, setEditing] = useState(false);
const { styles } = useStyles();
const openChatSettings = useOpenChatSettings(ChatSettingsTabs.Prompt);
const openChatSettings = useOpenChatSettings();
const [init, meta] = useSessionStore((s) => [
sessionSelectors.isSomeSessionActive(s),
sessionMetaSelectors.currentAgentMeta(s),
@@ -92,7 +94,7 @@ const SystemRole = memo(() => {
onAvatarClick={() => {
setOpen(false);
setEditing(false);
openChatSettings();
openChatSettings(ChatSettingsTabs.Prompt);
}}
style={{ marginBottom: 16 }}
/>
@@ -0,0 +1,18 @@
'use client';
import { memo } from 'react';
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useSessionStore } from '@/store/session';
import { sessionSelectors } from '@/store/session/selectors';
import SystemRoleContent from './SystemRoleContent';
const SystemRole = memo(() => {
const { isAgentEditable: showSystemRole } = useServerConfigStore(featureFlagsSelectors);
const isInbox = useSessionStore(sessionSelectors.isInboxSession);
return showSystemRole && !isInbox && <SystemRoleContent />;
});
export default SystemRole;
@@ -0,0 +1,88 @@
'use client';
import { ActionIcon, Icon } from '@lobehub/ui';
import { App, Dropdown, MenuProps } from 'antd';
import { MoreHorizontal, Search, Trash } from 'lucide-react';
import { memo, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
import SidebarHeader from '../SidebarHeader';
import TopicSearchBar from './TopicSearchBar';
const Header = memo(() => {
const { t } = useTranslation('chat');
const [topicLength, removeUnstarredTopic, removeAllTopic] = useChatStore((s) => [
topicSelectors.currentTopicLength(s),
s.removeUnstarredTopic,
s.removeSessionTopics,
]);
const [showSearch, setShowSearch] = useState(false);
const { modal } = App.useApp();
const items = useMemo<MenuProps['items']>(
() => [
{
icon: <Icon icon={Trash} />,
key: 'deleteUnstarred',
label: t('topic.removeUnstarred'),
onClick: () => {
modal.confirm({
centered: true,
okButtonProps: { danger: true },
onOk: removeUnstarredTopic,
title: t('topic.confirmRemoveUnstarred', { ns: 'chat' }),
});
},
},
{
danger: true,
icon: <Icon icon={Trash} />,
key: 'deleteAll',
label: t('topic.removeAll'),
onClick: () => {
modal.confirm({
centered: true,
okButtonProps: { danger: true },
onOk: removeAllTopic,
title: t('topic.confirmRemoveAll', { ns: 'chat' }),
});
},
},
],
[],
);
return showSearch ? (
<Flexbox padding={'12px 16px 4px'}>
<TopicSearchBar onClear={() => setShowSearch(false)} />
</Flexbox>
) : (
<SidebarHeader
actions={
<>
<ActionIcon icon={Search} onClick={() => setShowSearch(true)} size={'small'} />
<Dropdown
arrow={false}
menu={{
items: items,
onClick: ({ domEvent }) => {
domEvent.stopPropagation();
},
}}
trigger={['click']}
>
<ActionIcon icon={MoreHorizontal} size={'small'} />
</Dropdown>
</>
}
title={`${t('topic.title')} ${topicLength > 1 ? topicLength + 1 : ''}`}
/>
);
});
export default Header;
@@ -0,0 +1,29 @@
import { Icon, Tag } from '@lobehub/ui';
import { Typography } from 'antd';
import { useTheme } from 'antd-style';
import { MessageSquareDashed } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
const { Paragraph } = Typography;
const DefaultContent = memo(() => {
const { t } = useTranslation('common');
const theme = useTheme();
return (
<Flexbox align={'center'} gap={8} horizontal>
<Flexbox align={'center'} height={24} justify={'center'} width={24}>
<Icon color={theme.colorTextDescription} icon={MessageSquareDashed} />
</Flexbox>
<Paragraph ellipsis={{ rows: 1 }} style={{ margin: 0 }}>
{t('topic.defaultTitle', { ns: 'chat' })}
</Paragraph>
<Tag>{t('temp')}</Tag>
</Flexbox>
);
});
export default DefaultContent;
@@ -0,0 +1,55 @@
import { Skeleton } from 'antd';
import { createStyles } from 'antd-style';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
const useStyles = createStyles(({ css, prefixCls }) => ({
container: css`
display: flex;
flex-direction: column;
justify-content: center;
height: 44px;
padding: 8px;
.${prefixCls}-skeleton-content {
display: flex;
flex-direction: column;
}
`,
paragraph: css`
> li {
height: 24px !important;
}
`,
}));
export const Placeholder = memo(() => {
const { styles } = useStyles();
return (
<Skeleton
active
avatar={false}
className={styles.container}
paragraph={{
className: styles.paragraph,
rows: 1,
style: { marginBottom: 0 },
width: '100%',
}}
title={false}
/>
);
});
export const SkeletonList = memo(() => (
<Flexbox>
{Array.from({ length: 8 }).map((_, i) => (
<Placeholder key={i} />
))}
</Flexbox>
));
export default SkeletonList;
@@ -0,0 +1,211 @@
import { ActionIcon, EditableText, Icon } from '@lobehub/ui';
import { App, Dropdown, type MenuProps, Typography } from 'antd';
import { createStyles } from 'antd-style';
import {
LucideCopy,
LucideLoader2,
MoreVertical,
PencilLine,
Star,
Trash,
Wand2,
} from 'lucide-react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { useChatStore } from '@/store/chat';
const useStyles = createStyles(({ css }) => ({
content: css`
position: relative;
overflow: hidden;
flex: 1;
`,
title: css`
flex: 1;
height: 28px;
line-height: 28px;
text-align: start;
`,
}));
const { Paragraph } = Typography;
interface TopicContentProps {
fav?: boolean;
id: string;
showMore?: boolean;
title: string;
}
const TopicContent = memo<TopicContentProps>(({ id, title, fav, showMore }) => {
const { t } = useTranslation('common');
const [
editing,
favoriteTopic,
updateTopicTitle,
removeTopic,
autoRenameTopicTitle,
duplicateTopic,
isLoading,
] = useChatStore((s) => [
s.topicRenamingId === id,
s.favoriteTopic,
s.updateTopicTitle,
s.removeTopic,
s.autoRenameTopicTitle,
s.duplicateTopic,
s.topicLoadingIds.includes(id),
]);
const { styles, theme } = useStyles();
const toggleEditing = (visible?: boolean) => {
useChatStore.setState({ topicRenamingId: visible ? id : '' });
};
const { modal } = App.useApp();
const items = useMemo<MenuProps['items']>(
() => [
{
icon: <Icon icon={Wand2} />,
key: 'autoRename',
label: t('topic.actions.autoRename', { ns: 'chat' }),
onClick: () => {
autoRenameTopicTitle(id);
},
},
{
icon: <Icon icon={PencilLine} />,
key: 'rename',
label: t('rename'),
onClick: () => {
toggleEditing(true);
},
},
{
type: 'divider',
},
{
icon: <Icon icon={LucideCopy} />,
key: 'duplicate',
label: t('topic.actions.duplicate', { ns: 'chat' }),
onClick: () => {
duplicateTopic(id);
},
},
// {
// icon: <Icon icon={LucideDownload} />,
// key: 'export',
// label: t('topic.actions.export', { ns: 'chat' }),
// onClick: () => {
// configService.exportSingleTopic(sessionId, id);
// },
// },
{
type: 'divider',
},
// {
// icon: <Icon icon={Share2} />,
// key: 'share',
// label: t('share'),
// },
{
danger: true,
icon: <Icon icon={Trash} />,
key: 'delete',
label: t('delete'),
onClick: () => {
if (!id) return;
modal.confirm({
centered: true,
okButtonProps: { danger: true },
onOk: async () => {
await removeTopic(id);
},
title: t('topic.confirmRemoveTopic', { ns: 'chat' }),
});
},
},
],
[],
);
return (
<Flexbox
align={'center'}
gap={8}
horizontal
justify={'space-between'}
onDoubleClick={(e) => {
if (!id) return;
if (e.altKey) toggleEditing(true);
}}
>
<ActionIcon
color={fav && !isLoading ? theme.colorWarning : undefined}
fill={fav && !isLoading ? theme.colorWarning : 'transparent'}
icon={isLoading ? LucideLoader2 : Star}
onClick={(e) => {
e.stopPropagation();
if (!id) return;
favoriteTopic(id, !fav);
}}
size={'small'}
spin={isLoading}
/>
{!editing ? (
<Paragraph
className={styles.title}
ellipsis={{ rows: 1, tooltip: { placement: 'left', title } }}
style={{ margin: 0 }}
>
{title}
</Paragraph>
) : (
<EditableText
editing={editing}
onChangeEnd={(v) => {
if (title !== v) {
updateTopicTitle(id, v);
}
toggleEditing(false);
}}
onEditingChange={toggleEditing}
showEditIcon={false}
size={'small'}
style={{
height: 28,
}}
type={'pure'}
value={title}
/>
)}
{showMore && !editing && (
<Dropdown
arrow={false}
menu={{
items: items,
onClick: ({ domEvent }) => {
domEvent.stopPropagation();
},
}}
trigger={['click']}
>
<ActionIcon
className="topic-more"
icon={MoreVertical}
onClick={(e) => {
e.stopPropagation();
}}
size={'small'}
/>
</Dropdown>
)}
</Flexbox>
);
});
export default TopicContent;
@@ -0,0 +1,73 @@
import { createStyles } from 'antd-style';
import { memo, useState } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useChatStore } from '@/store/chat';
import { useGlobalStore } from '@/store/global';
import DefaultContent from './DefaultContent';
import TopicContent from './TopicContent';
const useStyles = createStyles(({ css, token, isDarkMode }) => ({
active: css`
background: ${isDarkMode ? token.colorFillSecondary : token.colorFillTertiary};
transition: background 200ms ${token.motionEaseOut};
&:hover {
background: ${token.colorFill};
}
`,
container: css`
cursor: pointer;
padding: 8px;
border-radius: ${token.borderRadius}px;
&:hover {
background: ${token.colorFillSecondary};
}
`,
split: css`
border-bottom: 1px solid ${token.colorSplit};
`,
}));
export interface ConfigCellProps {
active?: boolean;
fav?: boolean;
id?: string;
title: string;
}
const TopicItem = memo<ConfigCellProps>(({ title, active, id, fav }) => {
const { styles, cx } = useStyles();
const toggleConfig = useGlobalStore((s) => s.toggleMobileTopic);
const [toggleTopic] = useChatStore((s) => [s.switchTopic]);
const [isHover, setHovering] = useState(false);
return (
<Flexbox
align={'center'}
className={cx(styles.container, active && styles.active)}
distribution={'space-between'}
horizontal
onClick={() => {
toggleTopic(id);
toggleConfig(false);
}}
onMouseEnter={() => {
setHovering(true);
}}
onMouseLeave={() => {
setHovering(false);
}}
>
{!id ? (
<DefaultContent />
) : (
<TopicContent fav={fav} id={id} showMore={isHover} title={title} />
)}
</Flexbox>
);
});
export default TopicItem;
@@ -0,0 +1,105 @@
'use client';
import { EmptyCard } from '@lobehub/ui';
import { css, cx, useThemeMode } from 'antd-style';
import isEqual from 'fast-deep-equal';
import React, { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { imageUrl } from '@/const/url';
import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
import { useUserStore } from '@/store/user';
import { ChatTopic } from '@/types/topic';
import { Placeholder, SkeletonList } from './SkeletonList';
import TopicItem from './TopicItem';
const container = css`
> div {
padding-inline: 8px;
}
`;
export const Topic = memo(() => {
const { t } = useTranslation('chat');
const virtuosoRef = useRef<VirtuosoHandle>(null);
const { isDarkMode } = useThemeMode();
const [topicsInit, activeTopicId, topicLength] = useChatStore((s) => [
s.topicsInit,
s.activeTopicId,
topicSelectors.currentTopicLength(s),
]);
const [visible, updateGuideState] = useUserStore((s) => [
s.preference.guide?.topic,
s.updateGuideState,
]);
const topics = useChatStore(
(s) => [
{
favorite: false,
id: 'default',
title: t('topic.defaultTitle'),
} as ChatTopic,
...topicSelectors.displayTopics(s),
],
isEqual,
);
const itemContent = useCallback(
(index: number, { id, favorite, title }: ChatTopic) =>
index === 0 ? (
<TopicItem active={!activeTopicId} fav={favorite} title={title} />
) : (
<TopicItem active={activeTopicId === id} fav={favorite} id={id} key={id} title={title} />
),
[activeTopicId],
);
const activeIndex = topics.findIndex((topic) => topic.id === activeTopicId);
return !topicsInit ? (
<SkeletonList />
) : (
<Flexbox gap={2} height={'100%'} style={{ marginBottom: 12 }}>
{topicLength === 0 && (
<Flexbox flex={1} paddingInline={8}>
<EmptyCard
alt={t('topic.guide.desc')}
cover={imageUrl(`empty_topic_${isDarkMode ? 'dark' : 'light'}.webp`)}
desc={t('topic.guide.desc')}
height={120}
imageProps={{
priority: true,
}}
onVisibleChange={(visible) => {
updateGuideState({ topic: visible });
}}
style={{ marginBottom: 6 }}
title={t('topic.guide.title')}
visible={visible}
width={200}
/>
</Flexbox>
)}
<Virtuoso
className={cx(container)}
components={{ ScrollSeekPlaceholder: Placeholder }}
computeItemKey={(_, item) => item.id}
data={topics}
fixedItemHeight={44}
initialTopMostItemIndex={Math.max(activeIndex, 0)}
itemContent={itemContent}
overscan={44 * 10}
ref={virtuosoRef}
scrollSeekConfiguration={{
enter: (velocity) => Math.abs(velocity) > 350,
exit: (velocity) => Math.abs(velocity) < 10,
}}
/>
</Flexbox>
);
});
@@ -0,0 +1,42 @@
'use client';
import { SearchBar } from '@lobehub/ui';
import { useUnmount } from 'ahooks';
import { useResponsive } from 'antd-style';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useChatStore } from '@/store/chat';
const TopicSearchBar = memo<{ onClear?: () => void }>(({ onClear }) => {
const { t } = useTranslation('chat');
const [keywords, setKeywords] = useState('');
const { mobile } = useResponsive();
const [activeSessionId, useSearchTopics] = useChatStore((s) => [s.activeId, s.useSearchTopics]);
useSearchTopics(keywords, activeSessionId);
useUnmount(() => {
useChatStore.setState({ isSearchingTopic: false });
});
return (
<SearchBar
autoFocus
onBlur={() => {
if (keywords === '') onClear?.();
}}
onChange={(e) => {
const value = e.target.value;
setKeywords(value);
useChatStore.setState({ isSearchingTopic: !!value });
}}
placeholder={t('topic.searchPlaceholder')}
spotlight={!mobile}
type={mobile ? 'block' : 'ghost'}
value={keywords}
/>
);
});
export default TopicSearchBar;
@@ -0,0 +1,18 @@
import { Flexbox } from 'react-layout-kit';
import Header from './Header';
import { Topic } from './Topic';
import TopicSearchBar from './TopicSearchBar';
const TopicListContent = ({ mobile }: { mobile?: boolean }) => {
return (
<Flexbox gap={mobile ? 8 : 0} height={'100%'} style={{ overflow: 'hidden' }}>
{mobile ? <TopicSearchBar /> : <Header />}
<Flexbox gap={16} height={'100%'} style={{ paddingTop: 6, position: 'relative' }}>
<Topic />
</Flexbox>
</Flexbox>
);
};
export default TopicListContent;
@@ -1,3 +1,5 @@
'use client';
import { ActionIcon } from '@lobehub/ui';
import { PanelRightClose, PanelRightOpen } from 'lucide-react';
import { memo } from 'react';
@@ -1,3 +1,5 @@
'use client';
import { Avatar, ChatHeaderTitle } from '@lobehub/ui';
import { Skeleton } from 'antd';
import { memo } from 'react';
@@ -41,7 +43,7 @@ const Main = memo(() => {
<Avatar
avatar={avatar}
background={backgroundColor}
onClick={openChatSettings}
onClick={() => openChatSettings()}
size={40}
title={title}
/>
@@ -19,7 +19,7 @@ const TitleTags = memo(() => {
const showPlugin = useUserStore(modelProviderSelectors.isModelEnabledFunctionCall(model));
return (
<Flexbox gap={8} horizontal>
<Flexbox align={'center'} horizontal>
<ModelSwitchPanel>
<ModelTag model={model} />
</ModelSwitchPanel>
@@ -1,9 +1,8 @@
import { ChatHeader } from '@lobehub/ui';
import { memo } from 'react';
import HeaderAction from './HeaderAction';
import Main from './Main';
const Header = memo(() => <ChatHeader left={<Main />} right={<HeaderAction />} />);
const Header = () => <ChatHeader left={<Main />} right={<HeaderAction />} />;
export default Header;
@@ -1,3 +1,5 @@
'use client';
import isEqual from 'fast-deep-equal';
import { useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
@@ -1,18 +1,12 @@
'use client';
import { DraggablePanel, DraggablePanelContainer } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import dynamic from 'next/dynamic';
import { memo } from 'react';
import { createStyles, useResponsive } from 'antd-style';
import { PropsWithChildren, memo, useLayoutEffect } from 'react';
import SafeSpacing from '@/components/SafeSpacing';
import { CHAT_SIDEBAR_WIDTH } from '@/const/layoutTokens';
import { useGlobalStore } from '@/store/global';
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useSessionStore } from '@/store/session';
import { sessionSelectors } from '@/store/session/selectors';
import TopicListContent from '../../../features/TopicListContent';
const SystemRole = dynamic(() => import('./SystemRole'));
const useStyles = createStyles(({ css, token }) => ({
content: css`
@@ -29,15 +23,17 @@ const useStyles = createStyles(({ css, token }) => ({
`,
}));
const Desktop = memo(() => {
const Desktop = memo(({ children }: PropsWithChildren) => {
const { styles } = useStyles();
const { md = true, lg = true } = useResponsive();
const [showAgentSettings, toggleConfig] = useGlobalStore((s) => [
s.preference.showChatSideBar,
s.toggleChatSideBar,
]);
const { isAgentEditable: showSystemRole } = useServerConfigStore(featureFlagsSelectors);
const isInbox = useSessionStore(sessionSelectors.isInboxSession);
useLayoutEffect(() => {
toggleConfig(lg);
}, [lg]);
return (
<DraggablePanel
@@ -47,7 +43,7 @@ const Desktop = memo(() => {
}}
expand={showAgentSettings}
minWidth={CHAT_SIDEBAR_WIDTH}
mode={'fixed'}
mode={md ? 'fixed' : 'float'}
onExpandChange={toggleConfig}
placement={'right'}
showHandlerWideArea={false}
@@ -61,8 +57,7 @@ const Desktop = memo(() => {
}}
>
<SafeSpacing />
{showSystemRole && !isInbox && <SystemRole />}
<TopicListContent />
{children}
</DraggablePanelContainer>
</DraggablePanel>
);
@@ -0,0 +1,29 @@
import { Flexbox } from 'react-layout-kit';
import { LayoutProps } from '../type';
import ChatHeader from './ChatHeader';
import HotKeys from './HotKeys';
import SideBar from './SideBar';
const Layout = ({ children, topic, conversation }: LayoutProps) => {
return (
<>
<ChatHeader />
<Flexbox
height={'100%'}
horizontal
style={{ overflow: 'hidden', position: 'relative' }}
width={'100%'}
>
{conversation}
{children}
<SideBar>{topic}</SideBar>
</Flexbox>
<HotKeys />
</>
);
};
Layout.displayName = 'DesktopConversationLayout';
export default Layout;
@@ -1,7 +1,9 @@
'use client';
import { MobileNavBar } from '@lobehub/ui';
import { useRouter } from 'next/navigation';
import { memo, useState } from 'react';
import { useQueryRoute } from '@/hooks/useQueryRoute';
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
import SettingButton from '../../../features/SettingButton';
@@ -9,7 +11,7 @@ import ShareButton from '../../../features/ShareButton';
import ChatHeaderTitle from './ChatHeaderTitle';
const MobileHeader = memo(() => {
const router = useRouter();
const router = useQueryRoute();
const [open, setOpen] = useState(false);
const { isAgentEditable } = useServerConfigStore(featureFlagsSelectors);
@@ -32,7 +34,7 @@ const MobileHeader = memo(() => {
return (
<MobileNavBar
center={<ChatHeaderTitle />}
onBackClick={() => router.push('/chat')}
onBackClick={() => router.push('/chat', { query: { active: '' } })}
right={
<>
<ShareButton mobile open={open} setOpen={setOpen} />
@@ -1,12 +1,12 @@
'use client';
import { Modal } from '@lobehub/ui';
import { memo } from 'react';
import { PropsWithChildren, memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useGlobalStore } from '@/store/global';
import TopicListContent from '../../features/TopicListContent';
const Topics = memo(() => {
const Topics = memo(({ children }: PropsWithChildren) => {
const [showAgentSettings, toggleConfig] = useGlobalStore((s) => [
s.preference.mobileShowTopic,
s.toggleMobileTopic,
@@ -21,7 +21,7 @@ const Topics = memo(() => {
open={showAgentSettings}
title={t('topic.title')}
>
<TopicListContent mobile />
{children}
</Modal>
);
});
@@ -0,0 +1,19 @@
import MobileContentLayout from '@/components/server/MobileNavLayout';
import { LayoutProps } from '../type';
import ChatHeader from './ChatHeader';
import TopicModal from './TopicModal';
const Layout = ({ children, topic, conversation }: LayoutProps) => {
return (
<MobileContentLayout header={<ChatHeader />}>
{conversation}
{children}
<TopicModal>{topic}</TopicModal>
</MobileContentLayout>
);
};
Layout.displayName = 'MobileConversationLayout';
export default Layout;
@@ -0,0 +1,7 @@
import { ReactNode } from 'react';
export interface LayoutProps {
children: ReactNode;
conversation: ReactNode;
topic: ReactNode;
}
@@ -0,0 +1,108 @@
import { ActionIcon } from '@lobehub/ui';
import { Badge, Button, Tag } from 'antd';
import isEqual from 'fast-deep-equal';
import { LucideRotateCw, LucideTrash2, RotateCwIcon } from 'lucide-react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import ManifestPreviewer from '@/components/ManifestPreviewer';
import { useAgentStore } from '@/store/agent';
import { useToolStore } from '@/store/tool';
import { customPluginSelectors, toolSelectors } from '@/store/tool/selectors';
interface PluginStatusProps {
deprecated?: boolean;
id: string;
title?: string;
}
const PluginStatus = memo<PluginStatusProps>(({ title, id, deprecated }) => {
const { t } = useTranslation('common');
const [status, isCustom, reinstallCustomPlugin] = useToolStore((s) => [
toolSelectors.getManifestLoadingStatus(id)(s),
customPluginSelectors.isCustomPlugin(id)(s),
s.reinstallCustomPlugin,
]);
const manifest = useToolStore(toolSelectors.getManifestById(id), isEqual);
const removePlugin = useAgentStore((s) => s.removePlugin);
const renderStatus = useMemo(() => {
switch (status) {
case 'loading': {
return <Badge color={'blue'} status={'processing'} />;
}
case 'error': {
return (
<ActionIcon
icon={LucideRotateCw}
onClick={() => {
reinstallCustomPlugin(id);
}}
size={'small'}
title={t('retry')}
/>
);
}
default:
case 'success': {
return <Badge status={'success'} />;
}
}
}, [status]);
const tag =
// 废弃标签
deprecated ? (
<Tag bordered={false} color={'red'} style={{ marginRight: 0 }}>
{t('list.item.deprecated.title', { ns: 'plugin' })}
</Tag>
) : // 自定义标签
isCustom ? (
<Tag bordered={false} color={'gold'}>
{t('list.item.local.title', { ns: 'plugin' })}
</Tag>
) : null;
return (
<Flexbox gap={12} horizontal justify={'space-between'}>
<Flexbox align={'center'} gap={8} horizontal>
{title || id}
{tag}
</Flexbox>
{deprecated ? (
<ActionIcon
icon={LucideTrash2}
onClick={(e) => {
e.stopPropagation();
removePlugin(id);
}}
size={'small'}
title={t('plugin.clearDeprecated', { ns: 'setting' })}
/>
) : (
<Flexbox align={'center'} horizontal>
{isCustom ? (
<ActionIcon
icon={RotateCwIcon}
onClick={(e) => {
e.stopPropagation();
reinstallCustomPlugin(id);
}}
size={'small'}
title={t('dev.meta.manifest.refresh', { ns: 'plugin' })}
/>
) : null}
<ManifestPreviewer manifest={manifest || {}} trigger={'hover'}>
<Button icon={renderStatus} size={'small'} type={'text'} />
</ManifestPreviewer>
</Flexbox>
)}
</Flexbox>
);
});
export default PluginStatus;
@@ -0,0 +1,60 @@
'use client';
import { Avatar, Icon, Tag } from '@lobehub/ui';
import type { MenuProps } from 'antd';
import { Dropdown } from 'antd';
import isEqual from 'fast-deep-equal';
import { LucideToyBrick } from 'lucide-react';
import { memo } from 'react';
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
import { pluginHelpers, useToolStore } from '@/store/tool';
import { toolSelectors } from '@/store/tool/selectors';
import PluginStatus from './PluginStatus';
export interface PluginTagProps {
plugins: string[];
}
const PluginTag = memo<PluginTagProps>(({ plugins }) => {
const { showDalle } = useServerConfigStore(featureFlagsSelectors);
const list = useToolStore(toolSelectors.metaList(showDalle), isEqual);
const displayPlugin = useToolStore(toolSelectors.getMetaById(plugins[0]), isEqual);
if (plugins.length === 0) return null;
const items: MenuProps['items'] = plugins.map((id) => {
const item = list.find((i) => i.identifier === id);
const isDeprecated = !pluginHelpers.getPluginTitle(item?.meta);
const avatar = isDeprecated ? '♻️' : pluginHelpers.getPluginAvatar(item?.meta);
return {
icon: <Avatar avatar={avatar} size={24} style={{ marginLeft: -6, marginRight: 2 }} />,
key: id,
label: (
<PluginStatus
deprecated={isDeprecated}
id={id}
title={pluginHelpers.getPluginTitle(item?.meta)}
/>
),
};
});
const count = plugins.length;
return (
<Dropdown menu={{ items }}>
<div>
<Tag>
{<Icon icon={LucideToyBrick} />}
{pluginHelpers.getPluginTitle(displayPlugin) || plugins[0]}
{count > 1 && <div>({plugins.length - 1}+)</div>}
</Tag>
</div>
</Dropdown>
);
});
export default PluginTag;
@@ -0,0 +1,40 @@
'use client';
import { ActionIcon } from '@lobehub/ui';
import { AlignJustify } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { DESKTOP_HEADER_ICON_SIZE, MOBILE_HEADER_ICON_SIZE } from '@/const/layoutTokens';
import { useGlobalStore } from '@/store/global';
import { SidebarTabKey } from '@/store/global/initialState';
import { useSessionStore } from '@/store/session';
import { sessionSelectors } from '@/store/session/selectors';
import { pathString } from '@/utils/url';
const SettingButton = memo<{ mobile?: boolean }>(({ mobile }) => {
const isInbox = useSessionStore(sessionSelectors.isInboxSession);
const { t } = useTranslation('common');
const router = useRouter();
return (
<ActionIcon
icon={AlignJustify}
onClick={() => {
if (isInbox) {
useGlobalStore.setState({
sidebarKey: SidebarTabKey.Setting,
});
router.push('/settings/agent');
} else {
router.push(pathString('/chat/settings', { search: location.search }));
}
}}
size={mobile ? MOBILE_HEADER_ICON_SIZE : DESKTOP_HEADER_ICON_SIZE}
title={t('header.session', { ns: 'setting' })}
/>
);
});
export default SettingButton;
@@ -0,0 +1,78 @@
import { Avatar, ChatHeaderTitle, Logo, Markdown } from '@lobehub/ui';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import pkg from '@/../package.json';
import ModelTag from '@/components/ModelTag';
import ChatList from '@/features/Conversation/components/ChatList';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useSessionStore } from '@/store/session';
import { sessionMetaSelectors, sessionSelectors } from '@/store/session/selectors';
import PluginTag from '../PluginTag';
import { useStyles } from './style';
import { FieldType } from './type';
const Preview = memo<FieldType & { title?: string }>(
({ title, withSystemRole, withBackground, withFooter }) => {
const [model, plugins, systemRole] = useAgentStore((s) => [
agentSelectors.currentAgentModel(s),
agentSelectors.currentAgentPlugins(s),
agentSelectors.currentAgentSystemRole(s),
]);
const [isInbox, description, avatar, backgroundColor] = useSessionStore((s) => [
sessionSelectors.isInboxSession(s),
sessionMetaSelectors.currentAgentDescription(s),
sessionMetaSelectors.currentAgentAvatar(s),
sessionMetaSelectors.currentAgentBackgroundColor(s),
]);
const { t } = useTranslation('chat');
const { styles } = useStyles(withBackground);
const displayTitle = isInbox ? t('inbox.title') : title;
const displayDesc = isInbox ? t('inbox.desc') : description;
return (
<div className={styles.preview}>
<div className={withBackground ? styles.background : undefined} id={'preview'}>
<Flexbox className={styles.container} gap={16}>
<div className={styles.header}>
<Flexbox align={'flex-start'} gap={12} horizontal>
<Avatar avatar={avatar} background={backgroundColor} size={40} title={title} />
<ChatHeaderTitle
desc={displayDesc}
tag={
<>
<ModelTag model={model} />
{plugins?.length > 0 && <PluginTag plugins={plugins} />}
</>
}
title={displayTitle}
/>
</Flexbox>
{withSystemRole && systemRole && (
<div className={styles.role}>
<Markdown variant={'chat'}>{systemRole}</Markdown>
</div>
)}
</div>
<ChatList />
{withFooter ? (
<Flexbox align={'center'} className={styles.footer} gap={4}>
<Logo extra={'chat'} type={'combine'} />
<div className={styles.url}>{pkg.homepage}</div>
</Flexbox>
) : (
<div />
)}
</Flexbox>
</div>
</div>
);
},
);
export default Preview;
@@ -0,0 +1,164 @@
import { Form, type FormItemProps, Modal, type ModalProps } from '@lobehub/ui';
import { Button, Segmented, SegmentedProps, Switch } from 'antd';
import { memo, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { FORM_STYLE } from '@/const/layoutTokens';
import { useChatStore } from '@/store/chat';
import { useUserStore } from '@/store/user';
import { userProfileSelectors } from '@/store/user/selectors';
import Preview from './Preview';
import { FieldType, ImageType } from './type';
import { useScreenshot } from './useScreenshot';
enum Tab {
Screenshot = 'screenshot',
ShareGPT = 'sharegpt',
}
export const imageTypeOptions: SegmentedProps['options'] = [
{
label: 'JPG',
value: ImageType.JPG,
},
{
label: 'PNG',
value: ImageType.PNG,
},
{
label: 'SVG',
value: ImageType.SVG,
},
{
label: 'WEBP',
value: ImageType.WEBP,
},
];
const DEFAULT_FIELD_VALUE: FieldType = {
imageType: ImageType.JPG,
withBackground: true,
withFooter: false,
withPluginInfo: false,
withSystemRole: false,
};
const ShareModal = memo<ModalProps>(({ onCancel, open }) => {
const [fieldValue, setFieldValue] = useState<FieldType>(DEFAULT_FIELD_VALUE);
const [tab, setTab] = useState<Tab>(Tab.Screenshot);
const { t } = useTranslation('chat');
const avatar = useUserStore(userProfileSelectors.userAvatar);
const [shareLoading, shareToShareGPT] = useChatStore((s) => [s.shareLoading, s.shareToShareGPT]);
const { loading, onDownload, title } = useScreenshot(fieldValue.imageType);
const options: SegmentedProps['options'] = useMemo(
() => [
{
label: t('shareModal.screenshot'),
value: Tab.Screenshot,
},
{
label: 'ShareGPT',
value: Tab.ShareGPT,
},
],
[],
);
const settings: FormItemProps[] = useMemo(
() => [
{
children: <Switch />,
label: t('shareModal.withSystemRole'),
minWidth: undefined,
name: 'withSystemRole',
valuePropName: 'checked',
},
{
children: <Switch />,
hidden: tab !== Tab.Screenshot,
label: t('shareModal.withBackground'),
minWidth: undefined,
name: 'withBackground',
valuePropName: 'checked',
},
{
children: <Switch />,
hidden: tab !== Tab.Screenshot,
label: t('shareModal.withFooter'),
minWidth: undefined,
name: 'withFooter',
valuePropName: 'checked',
},
{
children: <Segmented options={imageTypeOptions} />,
hidden: tab !== Tab.Screenshot,
label: t('shareModal.imageType'),
minWidth: undefined,
name: 'imageType',
},
{
children: <Switch />,
hidden: tab !== Tab.ShareGPT,
label: t('shareModal.withPluginInfo'),
minWidth: undefined,
name: 'withPluginInfo',
valuePropName: 'checked',
},
],
[tab],
);
return (
<Modal
allowFullscreen
centered={false}
footer={
<>
{tab === Tab.Screenshot && (
<Button block loading={loading} onClick={onDownload} size={'large'} type={'primary'}>
{t('shareModal.download')}
</Button>
)}
{tab === Tab.ShareGPT && (
<Button
block
loading={shareLoading}
onClick={() => shareToShareGPT({ avatar, ...fieldValue })}
size={'large'}
type={'primary'}
>
{t('shareModal.shareToShareGPT')}
</Button>
)}
</>
}
maxHeight={false}
onCancel={onCancel}
open={open}
title={t('share', { ns: 'common' })}
>
<Flexbox gap={16}>
<Segmented
block
onChange={(value) => setTab(value as Tab)}
options={options}
style={{ width: '100%' }}
value={tab}
/>
{tab === Tab.Screenshot && <Preview title={title} {...fieldValue} />}
<Form
initialValues={DEFAULT_FIELD_VALUE}
items={settings}
itemsType={'flat'}
onValuesChange={(_, v) => setFieldValue(v)}
{...FORM_STYLE}
/>
</Flexbox>
</Modal>
);
});
export default ShareModal;
@@ -0,0 +1,43 @@
'use client';
import { ActionIcon } from '@lobehub/ui';
import { Share2 } from 'lucide-react';
import dynamic from 'next/dynamic';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import useMergeState from 'use-merge-value';
import { DESKTOP_HEADER_ICON_SIZE, MOBILE_HEADER_ICON_SIZE } from '@/const/layoutTokens';
import { useChatStore } from '@/store/chat';
const ShareModal = dynamic(() => import('./ShareModal'));
interface ShareButtonProps {
mobile?: boolean;
open?: boolean;
setOpen?: (open: boolean) => void;
}
const ShareButton = memo<ShareButtonProps>(({ mobile, setOpen, open }) => {
const [isModalOpen, setIsModalOpen] = useMergeState(false, {
defaultValue: false,
onChange: setOpen,
value: open,
});
const { t } = useTranslation('common');
const [shareLoading] = useChatStore((s) => [s.shareLoading]);
return (
<>
<ActionIcon
icon={Share2}
loading={shareLoading}
onClick={() => setIsModalOpen(true)}
size={mobile ? MOBILE_HEADER_ICON_SIZE : DESKTOP_HEADER_ICON_SIZE}
title={t('share')}
/>
<ShareModal onCancel={() => setIsModalOpen(false)} open={isModalOpen} />
</>
);
});
export default ShareButton;
@@ -0,0 +1,71 @@
import { createStyles } from 'antd-style';
import { imageUrl } from '@/const/url';
export const useStyles = createStyles(({ css, token, stylish, cx }, withBackground: boolean) => ({
background: css`
padding: 24px;
background-color: ${token.colorBgLayout};
background-image: url(${imageUrl('screenshot_background.webp')});
background-position: center;
background-size: 120% 120%;
`,
container: cx(
withBackground &&
css`
overflow: hidden;
border: 2px solid ${token.colorBorder};
border-radius: ${token.borderRadiusLG}px;
`,
css`
background: ${token.colorBgLayout};
`,
),
footer: css`
padding: 16px;
border-top: 1px solid ${token.colorBorder};
`,
header: css`
margin-bottom: -24px;
padding: 16px;
background: ${token.colorBgContainer};
border-bottom: 1px solid ${token.colorBorder};
`,
preview: cx(
stylish.noScrollbar,
css`
overflow: hidden scroll;
width: 100%;
max-height: 40dvh;
background: ${token.colorBgLayout};
border: 1px solid ${token.colorBorder};
border-radius: ${token.borderRadiusLG}px;
* {
pointer-events: none;
::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
}
}
`,
),
role: css`
margin-top: 12px;
padding-top: 12px;
opacity: 0.75;
border-top: 1px dashed ${token.colorBorderSecondary};
* {
font-size: 12px !important;
}
`,
url: css`
color: ${token.colorTextDescription};
`,
}));
@@ -0,0 +1,14 @@
export enum ImageType {
JPG = 'jpg',
PNG = 'png',
SVG = 'svg',
WEBP = 'webp',
}
export type FieldType = {
imageType: ImageType;
withBackground: boolean;
withFooter: boolean;
withPluginInfo: boolean;
withSystemRole: boolean;
};
@@ -0,0 +1,60 @@
import dayjs from 'dayjs';
import { domToJpeg, domToPng, domToSvg, domToWebp } from 'modern-screenshot';
import { useCallback, useState } from 'react';
import { useSessionStore } from '@/store/session';
import { sessionMetaSelectors } from '@/store/session/selectors';
import { ImageType } from './type';
export const useScreenshot = (imageType: ImageType) => {
const [loading, setLoading] = useState(false);
const title = useSessionStore(sessionMetaSelectors.currentAgentTitle);
const handleDownload = useCallback(async () => {
setLoading(true);
try {
let screenshotFn: any;
switch (imageType) {
case ImageType.JPG: {
screenshotFn = domToJpeg;
break;
}
case ImageType.PNG: {
screenshotFn = domToPng;
break;
}
case ImageType.SVG: {
screenshotFn = domToSvg;
break;
}
case ImageType.WEBP: {
screenshotFn = domToWebp;
break;
}
}
const dataUrl = await screenshotFn(document.querySelector('#preview') as HTMLDivElement, {
features: {
// 不启用移除控制符,否则会导致 safari emoji 报错
removeControlCharacter: false,
},
scale: 2,
});
const link = document.createElement('a');
link.download = `LobeChat_${title}_${dayjs().format('YYYY-MM-DD')}.${imageType}`;
link.href = dataUrl;
link.click();
setLoading(false);
} catch (error) {
console.error('Failed to download image', error);
setLoading(false);
}
}, [imageType, title]);
return {
loading,
onDownload: handleDownload,
title,
};
};
@@ -1,3 +1,5 @@
'use client';
import { Avatar, Icon } from '@lobehub/ui';
import { Button } from 'antd';
import { createStyles } from 'antd-style';
+11
View File
@@ -0,0 +1,11 @@
import ServerLayout from '@/components/server/ServerLayout';
import Desktop from './_layout/Desktop';
import Mobile from './_layout/Mobile';
import { LayoutProps } from './_layout/type';
const Layout = ServerLayout<LayoutProps>({ Desktop, Mobile });
Layout.displayName = 'ChatConversationLayout';
export default Layout;
+19
View File
@@ -0,0 +1,19 @@
import { isMobileDevice } from '@/utils/responsive';
import PageTitle from '../features/PageTitle';
import TelemetryNotification from './features/TelemetryNotification';
const Page = () => {
const mobile = isMobileDevice();
return (
<>
<PageTitle />
<TelemetryNotification mobile={mobile} />
</>
);
};
Page.displayName = 'Chat';
export default Page;
@@ -0,0 +1,22 @@
'use client';
import { DraggablePanelBody } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { PropsWithChildren, memo } from 'react';
const useStyles = createStyles(
({ css }) => css`
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 8px 0;
`,
);
const PanelBody = memo<PropsWithChildren>(({ children }) => {
const { styles } = useStyles();
return <DraggablePanelBody className={styles}>{children}</DraggablePanelBody>;
});
export default PanelBody;
@@ -1,3 +1,5 @@
'use client';
import { ActionIcon, Logo } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { MessageSquarePlus } from 'lucide-react';
@@ -0,0 +1,15 @@
import { PropsWithChildren } from 'react';
import PanelBody from './PanelBody';
import Header from './SessionHeader';
const DesktopLayout = ({ children }: PropsWithChildren) => {
return (
<>
<Header />
<PanelBody>{children}</PanelBody>
</>
);
};
export default DesktopLayout;
@@ -0,0 +1,41 @@
'use client';
import { ActionIcon, Logo, MobileNavBar } from '@lobehub/ui';
import { MessageSquarePlus } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { memo } from 'react';
import { MOBILE_HEADER_ICON_SIZE } from '@/const/layoutTokens';
import SyncStatusInspector from '@/features/SyncStatusInspector';
import UserAvatar from '@/features/User/UserAvatar';
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useSessionStore } from '@/store/session';
import { mobileHeaderSticky } from '@/styles/mobileHeader';
const Header = memo(() => {
const [createSession] = useSessionStore((s) => [s.createSession]);
const router = useRouter();
const { showCreateSession, enableWebrtc } = useServerConfigStore(featureFlagsSelectors);
return (
<MobileNavBar
center={<Logo type={'text'} />}
left={<UserAvatar onClick={() => router.push('/me')} size={32} />}
right={
<>
{enableWebrtc && <SyncStatusInspector mobile />}
{showCreateSession && (
<ActionIcon
icon={MessageSquarePlus}
onClick={() => createSession()}
size={MOBILE_HEADER_ICON_SIZE}
/>
)}
</>
}
style={mobileHeaderSticky}
/>
);
});
export default Header;
@@ -0,0 +1,19 @@
import { PropsWithChildren } from 'react';
import MobileContentLayout from '@/components/server/MobileNavLayout';
import SessionSearchBar from '../../features/SessionSearchBar';
import SessionHeader from './SessionHeader';
const MobileLayout = ({ children }: PropsWithChildren) => {
return (
<MobileContentLayout header={<SessionHeader />} withNav>
<div style={{ padding: '8px 16px' }}>
<SessionSearchBar mobile />
</div>
{children}
</MobileContentLayout>
);
};
export default MobileLayout;
+26
View File
@@ -0,0 +1,26 @@
import Migration from '@/app/(main)/chat/features/Migration';
import ServerLayout from '@/components/server/ServerLayout';
import Desktop from './_layout/Desktop';
import Mobile from './_layout/Mobile';
import SessionHydration from './features/SessionHydration';
import SessionListContent from './features/SessionListContent';
const Layout = ServerLayout({ Desktop, Mobile });
const Session = () => {
return (
<>
<Migration>
<Layout>
<SessionListContent />
</Layout>
</Migration>
<SessionHydration />
</>
);
};
Session.displayName = 'Session';
export default Session;
@@ -0,0 +1,39 @@
'use client';
import { useResponsive } from 'antd-style';
import { useQueryState } from 'nuqs';
import { parseAsString } from 'nuqs/server';
import { memo, useEffect } from 'react';
import { createStoreUpdater } from 'zustand-utils';
import { useSessionStore } from '@/store/session';
// sync outside state to useSessionStore
const SessionHydration = memo(() => {
const useStoreUpdater = createStoreUpdater(useSessionStore);
const { mobile } = useResponsive();
useStoreUpdater('isMobile', mobile);
// two-way bindings the url and session store
const [session, setSession] = useQueryState(
'session',
parseAsString.withDefault('inbox').withOptions({ history: 'replace', throttleMs: 500 }),
);
useStoreUpdater('activeId', session);
useEffect(() => {
const unsubscribe = useSessionStore.subscribe(
(s) => s.activeId,
(state) => setSession(state),
);
return () => {
unsubscribe();
};
}, []);
return null;
});
export default SessionHydration;
@@ -16,7 +16,7 @@ import SessionList from './List';
import ConfigGroupModal from './Modals/ConfigGroupModal';
import RenameGroupModal from './Modals/RenameGroupModal';
const SessionDefaultMode = memo(() => {
const SessionListContent = memo(() => {
const { t } = useTranslation('chat');
const [activeGroupId, setActiveGroupId] = useState<string>();
@@ -98,6 +98,4 @@ const SessionDefaultMode = memo(() => {
);
});
SessionDefaultMode.displayName = 'SessionDefaultMode';
export default SessionDefaultMode;
export default SessionListContent;
@@ -1,13 +1,9 @@
import { Empty } from 'antd';
import { createStyles, useResponsive } from 'antd-style';
import Link from 'next/link';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Center } from 'react-layout-kit';
import LazyLoad from 'react-lazy-load';
import { SESSION_CHAT_URL } from '@/const/url';
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useSessionStore } from '@/store/session';
import { sessionSelectors } from '@/store/session/selectors';
import { LobeAgentSession } from '@/types/session';
@@ -28,9 +24,7 @@ interface SessionListProps {
showAddButton?: boolean;
}
const SessionList = memo<SessionListProps>(({ dataSource, groupId, showAddButton = true }) => {
const { t } = useTranslation('chat');
const isInit = useSessionStore((s) => sessionSelectors.isSessionListInit(s));
const { showCreateSession } = useServerConfigStore(featureFlagsSelectors);
const { styles } = useStyles();
const { mobile } = useResponsive();
@@ -46,12 +40,8 @@ const SessionList = memo<SessionListProps>(({ dataSource, groupId, showAddButton
</Link>
</LazyLoad>
))
) : showCreateSession ? (
showAddButton && <AddButton groupId={groupId} />
) : (
<Center>
<Empty description={t('emptyAgent')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Center>
showAddButton && <AddButton groupId={groupId} />
);
});
@@ -1,3 +1,5 @@
'use client';
import { memo } from 'react';
import { useSessionStore } from '@/store/session';
@@ -11,6 +13,4 @@ const SessionListContent = memo(() => {
return isSearching ? <SearchMode /> : <DefaultMode />;
});
SessionListContent.displayName = 'SessionListContent';
export default SessionListContent;
@@ -1,11 +1,12 @@
'use client';
import { SearchBar } from '@lobehub/ui';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useSessionStore } from '@/store/session';
const SessionSearchBar = memo<{ mobile?: boolean }>(({ mobile: controlledMobile }) => {
const SessionSearchBar = memo<{ mobile?: boolean }>(({ mobile }) => {
const { t } = useTranslation('chat');
const [keywords, useSearchSessions, updateSearchKeywords] = useSessionStore((s) => [
@@ -16,9 +17,6 @@ const SessionSearchBar = memo<{ mobile?: boolean }>(({ mobile: controlledMobile
const { isValidating } = useSearchSessions(keywords);
const isMobile = useIsMobile();
const mobile = controlledMobile ?? isMobile;
return (
<SearchBar
allowClear
+32
View File
@@ -0,0 +1,32 @@
'use client';
import { ReactNode } from 'react';
import { Flexbox } from 'react-layout-kit';
import FolderPanel from '@/features/FolderPanel';
type Props = { children: ReactNode; session: ReactNode };
const Layout = ({ children, session }: Props) => {
return (
<Flexbox
height={'100%'}
horizontal
style={{ maxWidth: 'calc(100vw - 64px)', overflow: 'hidden', position: 'relative' }}
width={'100%'}
>
<FolderPanel>{session}</FolderPanel>
<Flexbox
flex={1}
id={'lobe-conversion-container'}
style={{ overflow: 'hidden', position: 'relative' }}
>
{children}
</Flexbox>
</Flexbox>
);
};
Layout.displayName = 'DesktopChatLayout';
export default Layout;
@@ -1,39 +0,0 @@
'use client';
import { DraggablePanelBody } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { memo } from 'react';
import FolderPanel from '@/features/FolderPanel';
import SessionListContent from '../../features/SessionListContent';
import Header from './SessionHeader';
const useStyles = createStyles(({ stylish, css, cx }) =>
cx(
stylish.noScrollbar,
css`
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 8px 0;
`,
),
);
const Sessions = memo(() => {
const { styles } = useStyles();
return (
<FolderPanel>
<Header />
<DraggablePanelBody className={styles}>
<SessionListContent />
</DraggablePanelBody>
</FolderPanel>
);
});
Sessions.displayName = 'SessionsList';
export default Sessions;
@@ -1,24 +0,0 @@
import { Flexbox } from 'react-layout-kit';
import { LayoutProps } from '../type';
import ResponsiveSessionList from './SessionList';
const Layout = ({ children }: LayoutProps) => {
return (
<>
<ResponsiveSessionList />
<Flexbox
flex={1}
height={'100%'}
id={'lobe-conversion-container'}
style={{ position: 'relative' }}
>
{children}
</Flexbox>
</>
);
};
Layout.displayName = 'DesktopChatLayout';
export default Layout;
+50
View File
@@ -0,0 +1,50 @@
'use client';
import { createStyles } from 'antd-style';
import { ReactNode, memo } from 'react';
import { useQuery } from '@/hooks/useQuery';
type Props = { children: ReactNode; session: ReactNode };
const useStyles = createStyles(({ css, token }) => ({
active: css`
transform: translateX(0);
`,
mask: css`
position: absolute;
z-index: 101;
inset: 0;
background: ${token.colorBgMask};
backdrop-filter: blur(2px);
`,
subPage: css`
position: absolute;
z-index: 102;
inset: 0;
transform: translateX(100%);
background: ${token.colorBgLayout};
box-shadow: ${token.boxShadow};
transition: transform 0.3s ${token.motionEaseInOut};
`,
}));
const Layout = memo<Props>(({ children, session }) => {
const { active } = useQuery();
const { cx, styles } = useStyles();
return (
<>
{session}
{active && <div className={styles.mask} />}
<div className={cx(styles.subPage, active && styles.active)}>{children}</div>
</>
);
});
Layout.displayName = 'MobileChatLayout';
export default Layout;
@@ -1,9 +0,0 @@
import { LayoutProps } from '../type';
const Layout = ({ children }: LayoutProps) => {
return children;
};
Layout.displayName = 'MobileChatLayout';
export default Layout;
+1
View File
@@ -2,4 +2,5 @@ import { ReactNode } from 'react';
export interface LayoutProps {
children: ReactNode;
session: ReactNode;
}
+5
View File
@@ -0,0 +1,5 @@
'use client';
import dynamic from 'next/dynamic';
export default dynamic(() => import('@/components/Error'));
@@ -13,7 +13,7 @@ const SettingButton = memo<{ mobile?: boolean }>(({ mobile }) => {
return (
<ActionIcon
icon={AlignJustify}
onClick={openChatSettings}
onClick={() => openChatSettings()}
size={mobile ? MOBILE_HEADER_ICON_SIZE : DESKTOP_HEADER_ICON_SIZE}
title={t('header.session', { ns: 'setting' })}
/>
+3
View File
@@ -0,0 +1,3 @@
import dynamic from 'next/dynamic';
export default dynamic(() => import('@/components/404'));
-25
View File
@@ -1,25 +0,0 @@
import { isMobileDevice } from '@/utils/responsive';
import DesktopPage from './(desktop)';
import MobilePage from './(mobile)';
import SessionHydration from './components/SessionHydration';
import Migration from './features/Migration';
import PageTitle from './features/PageTitle';
const Page = () => {
const mobile = isMobileDevice();
const Page = mobile ? MobilePage : DesktopPage;
return (
<>
<Migration>
<PageTitle />
<Page />
</Migration>
<SessionHydration />
</>
);
};
export default Page;
@@ -6,7 +6,6 @@ import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { mobileHeaderSticky } from '@/styles/mobileHeader';
import { pathString } from '@/utils/url';
import HeaderContent from '../../features/HeaderContent';
@@ -17,7 +16,7 @@ const Header = memo(() => {
return (
<MobileNavBar
center={<MobileNavBarTitle title={t('header.session')} />}
onBackClick={() => router.push(pathString('/chat/mobile', { search: location.search }))}
onBackClick={() => router.back()}
right={<HeaderContent />}
showBackButton
style={mobileHeaderSticky}
+2 -1
View File
@@ -26,6 +26,7 @@ const useStyles = createStyles(
background: ${inverseTheme ? rgba(token.colorTextTertiary, 0.15) : token.colorFillTertiary};
border-radius: ${token.borderRadius}px;
box-shadow: 0 0 0 1px ${rgba(token.colorBorder, 0.1)} inset;
}
`,
);
@@ -67,7 +68,7 @@ const HotKeys = memo<HotKeysProps>(({ keys, desc, inverseTheme }) => {
if (!desc) return content;
return (
<Flexbox gap={16} horizontal>
<Flexbox align={'center'} gap={4} style={{ paddingBottom: 4 }}>
{desc}
{content}
</Flexbox>
@@ -1,7 +1,7 @@
'use client';
import { useQueryState } from 'nuqs';
import { memo, useEffect } from 'react';
import { memo, useLayoutEffect } from 'react';
import { createStoreUpdater } from 'zustand-utils';
import { useChatStore } from '@/store/chat';
@@ -14,7 +14,7 @@ const ChatHydration = memo(() => {
const [topic, setTopic] = useQueryState('topic', { history: 'replace', throttleMs: 500 });
useStoreUpdater('activeTopicId', topic);
useEffect(() => {
useLayoutEffect(() => {
const unsubscribe = useChatStore.subscribe(
(s) => s.activeTopicId,
(state) => {
+2
View File
@@ -5,6 +5,8 @@ import { merge } from '@/utils/merge';
export const INBOX_SESSION_ID = 'inbox';
export const WELCOME_GUIDE_CHAT_ID = 'welcome';
export const DEFAULT_AGENT_LOBE_SESSION: LobeAgentSession = {
config: DEFAULT_AGENT_CONFIG,
createdAt: Date.now(),
+5 -1
View File
@@ -1,3 +1,4 @@
import qs from 'query-string';
import urlJoin from 'url-join';
import { withBasePath } from '@/utils/basePath';
@@ -40,7 +41,10 @@ export const AGENTS_INDEX_GITHUB = 'https://github.com/lobehub/lobe-chat-agents'
export const AGENTS_INDEX_GITHUB_ISSUE = urlJoin(AGENTS_INDEX_GITHUB, 'issues/new');
export const SESSION_CHAT_URL = (id: string = INBOX_SESSION_ID, mobile?: boolean) =>
mobile ? `/chat/mobile?session=${id}` : `/chat?session=${id}`;
qs.stringifyUrl({
query: mobile ? { active: mobile, session: id } : { session: id },
url: '/chat',
});
export const imageUrl = (filename: string) => withBasePath(`/images/${filename}`);
@@ -1,13 +1,21 @@
'use client';
import { Skeleton } from 'antd';
import { createStyles } from 'antd-style';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
const useStyles = createStyles(({ css, prefixCls }) => ({
user: css`
message: css`
display: flex;
gap: 12px;
.${prefixCls}-skeleton-header {
padding: 0;
}
`,
right: css`
flex-direction: row-reverse;
gap: 16px;
.${prefixCls}-skeleton-paragraph {
display: flex;
@@ -20,18 +28,24 @@ interface SkeletonListProps {
mobile?: boolean;
}
const SkeletonList = memo<SkeletonListProps>(({ mobile }) => {
const { styles } = useStyles();
const { cx, styles } = useStyles();
return (
<Flexbox gap={24} padding={12} style={{ marginTop: 24 + (mobile ? 0 : 64) }}>
<Flexbox gap={24} padding={mobile ? 8 : 12} style={{ marginTop: 24 + (mobile ? 0 : 64) }}>
<Skeleton
active
avatar={{ size: 40 }}
className={styles.user}
paragraph={{ width: ['50%', '30%'] }}
avatar={{ size: mobile ? 32 : 40 }}
className={styles.message}
paragraph={{ width: mobile ? ['80%', '40%'] : ['50%', '30%'] }}
title={false}
/>
<Skeleton
active
avatar={{ size: mobile ? 32 : 40 }}
className={cx(styles.message, styles.right)}
paragraph={{ width: mobile ? ['80%', '40%'] : ['50%', '30%'] }}
title={false}
/>
<Skeleton active avatar={{ size: 40 }} paragraph={{ width: ['50%', '30%'] }} title={false} />
</Flexbox>
);
});
@@ -0,0 +1,22 @@
'use client';
import { memo } from 'react';
import { WELCOME_GUIDE_CHAT_ID } from '@/const/session';
import Item from '../ChatItem';
import InboxWelcome from '../InboxWelcome';
const ItemContent = memo<{ id: string; index: number; mobile?: boolean }>(
({ index, id, mobile }) => {
if (id === WELCOME_GUIDE_CHAT_ID) return <InboxWelcome />;
return index === 0 ? (
<div style={{ height: 24 + (mobile ? 0 : 64) }} />
) : (
<Item id={id} index={index - 1} />
);
},
);
export default ItemContent;
@@ -1,31 +1,18 @@
'use client';
import isEqual from 'fast-deep-equal';
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import { Flexbox } from 'react-layout-kit';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { WELCOME_GUIDE_CHAT_ID } from '@/const/session';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import { isMobileScreen } from '@/utils/screen';
import { useInitConversation } from '../../hooks/useInitConversation';
import AutoScroll from '../AutoScroll';
import Item from '../ChatItem';
import InboxWelcome from '../InboxWelcome';
import SkeletonList from '../SkeletonList';
const WELCOME_ID = 'welcome';
const itemContent = (index: number, id: string) => {
const isMobile = isMobileScreen();
if (id === WELCOME_ID) return <InboxWelcome />;
return index === 0 ? (
<div style={{ height: 24 + (isMobile ? 0 : 64) }} />
) : (
<Item id={id} index={index - 1} />
);
};
import ItemContent from './ItemContent';
interface VirtualizedListProps {
mobile?: boolean;
@@ -44,7 +31,9 @@ const VirtualizedList = memo<VirtualizedListProps>(({ mobile }) => {
const data = useChatStore((s) => {
const showInboxWelcome = chatSelectors.showInboxWelcome(s);
const ids = showInboxWelcome ? [WELCOME_ID] : chatSelectors.currentChatIDsWithGuideMessage(s);
const ids = showInboxWelcome
? [WELCOME_GUIDE_CHAT_ID]
: chatSelectors.currentChatIDsWithGuideMessage(s);
return ['empty', ...ids];
}, isEqual);
@@ -78,7 +67,7 @@ const VirtualizedList = memo<VirtualizedListProps>(({ mobile }) => {
// increaseViewportBy={overscan}
initialTopMostItemIndex={data?.length - 1}
isScrolling={setIsScrolling}
itemContent={itemContent}
itemContent={(index, id) => <ItemContent id={id} index={index} mobile={mobile} />}
overscan={overscan}
ref={virtuosoRef}
/>
+13 -20
View File
@@ -1,5 +1,4 @@
import { createStyles } from 'antd-style';
import { ReactNode, Suspense, lazy, memo } from 'react';
import { ReactNode, Suspense, lazy } from 'react';
import { Flexbox } from 'react-layout-kit';
import ChatHydration from '@/components/StoreHydration/ChatHydration';
@@ -8,26 +7,12 @@ import SkeletonList from './components/SkeletonList';
const ChatList = lazy(() => import('./components/VirtualizedList'));
const useStyles = createStyles(
({ css, responsive }) => css`
position: relative;
overflow-y: auto;
height: 100%;
${responsive.mobile} {
width: 100%;
}
`,
);
interface ConversationProps {
chatInput: ReactNode;
mobile?: boolean;
}
const Conversation = memo<ConversationProps>(({ chatInput, mobile }) => {
const { styles } = useStyles();
const Conversation = ({ chatInput, mobile }: ConversationProps) => {
return (
<Flexbox
flex={1}
@@ -35,15 +20,23 @@ const Conversation = memo<ConversationProps>(({ chatInput, mobile }) => {
// `relative` is required, ChatInput's absolute position needs it
style={{ position: 'relative' }}
>
<div className={styles}>
<Flexbox
height={'100%'}
style={{
overflowX: 'hidden',
overflowY: 'auto',
position: 'relative',
}}
width={'100%'}
>
<Suspense fallback={<SkeletonList mobile={mobile} />}>
<ChatList mobile={mobile} />
</Suspense>
</div>
</Flexbox>
{chatInput}
<ChatHydration />
</Flexbox>
);
});
};
export default Conversation;
+18 -8
View File
@@ -1,7 +1,9 @@
'use client';
import { DraggablePanel, DraggablePanelContainer } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { createStyles, useResponsive } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { PropsWithChildren, memo, useState } from 'react';
import { PropsWithChildren, memo, useLayoutEffect, useState } from 'react';
import { FOLDER_WIDTH } from '@/const/layoutTokens';
import { useGlobalStore } from '@/store/global';
@@ -15,6 +17,7 @@ export const useStyles = createStyles(({ css, token }) => ({
}));
const FolderPanel = memo<PropsWithChildren>(({ children }) => {
const { md = true } = useResponsive();
const { styles } = useStyles();
const [sessionsWidth, sessionExpandable, updatePreference] = useGlobalStore((s) => [
s.preference.sessionsWidth,
@@ -24,6 +27,17 @@ const FolderPanel = memo<PropsWithChildren>(({ children }) => {
const [tmpWidth, setWidth] = useState(sessionsWidth);
if (tmpWidth !== sessionsWidth) setWidth(sessionsWidth);
const handleExpand = (expand: boolean) => {
updatePreference({
sessionsWidth: expand ? 320 : 0,
showSessionPanel: expand,
});
};
useLayoutEffect(() => {
handleExpand(md);
}, [md]);
return (
<DraggablePanel
className={styles.panel}
@@ -31,12 +45,8 @@ const FolderPanel = memo<PropsWithChildren>(({ children }) => {
expand={sessionExpandable}
maxWidth={400}
minWidth={FOLDER_WIDTH}
onExpandChange={(expand) => {
updatePreference({
sessionsWidth: expand ? 320 : 0,
showSessionPanel: expand,
});
}}
mode={md ? 'fixed' : 'float'}
onExpandChange={handleExpand}
onSizeChange={(_, size) => {
if (!size) return;

Some files were not shown because too many files have changed in this diff Show More