feat: Polish CMDK (#12011)

* fix: Hide proxy command for web

* style: add close transition

* fix: cmdk close animation

* style: show current theme
This commit is contained in:
René Wang
2026-02-02 09:51:31 +08:00
committed by arvinxx
parent 501352e035
commit bebfb461e9
9 changed files with 136 additions and 49 deletions

View File

@@ -188,6 +188,7 @@
"cmdk.submitIssue": "Submit Issue",
"cmdk.theme": "Theme",
"cmdk.themeAuto": "Auto",
"cmdk.themeCurrent": "Current",
"cmdk.themeDark": "Dark",
"cmdk.themeLight": "Light",
"cmdk.toOpen": "Open",

View File

@@ -188,6 +188,7 @@
"cmdk.submitIssue": "提交问题",
"cmdk.theme": "主题",
"cmdk.themeAuto": "跟随系统",
"cmdk.themeCurrent": "当前",
"cmdk.themeDark": "深色模式",
"cmdk.themeLight": "浅色模式",
"cmdk.toOpen": "打开",

View File

@@ -18,6 +18,7 @@ import type { ValidSearchType } from './utils/queryParser';
interface CommandMenuContextValue {
menuContext: MenuContext;
mounted: boolean;
onClose: () => void;
page: PageType | undefined;
pages: PageType[];
pathname: string | null;
@@ -38,10 +39,11 @@ const CommandMenuContext = createContext<CommandMenuContextValue | undefined>(un
interface CommandMenuProviderProps {
children: ReactNode;
onClose: () => void;
pathname: string | null;
}
export const CommandMenuProvider = ({ children, pathname }: CommandMenuProviderProps) => {
export const CommandMenuProvider = ({ children, onClose, pathname }: CommandMenuProviderProps) => {
const [pages, setPages] = useState<PageType[]>([]);
const [search, setSearchState] = useState('');
const [typeFilter, setTypeFilterState] = useState<ValidSearchType | undefined>(undefined);
@@ -71,6 +73,7 @@ export const CommandMenuProvider = ({ children, pathname }: CommandMenuProviderP
() => ({
menuContext,
mounted: true, // Always true after initial render since provider only mounts on client
onClose,
page,
pages,
pathname,
@@ -86,6 +89,7 @@ export const CommandMenuProvider = ({ children, pathname }: CommandMenuProviderP
}),
[
menuContext,
onClose,
page,
pages,
pathname,

View File

@@ -1,5 +1,6 @@
import { Command } from 'cmdk';
import { Monitor, Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -9,25 +10,41 @@ import { useCommandMenu } from './useCommandMenu';
const ThemeMenu = memo(() => {
const { t } = useTranslation('common');
const { handleThemeChange } = useCommandMenu();
const { theme } = useTheme();
return (
<>
<Command.Item onSelect={() => handleThemeChange('light')} value="theme-light">
<Sun className={styles.icon} />
<div className={styles.itemContent}>
<div className={styles.itemLabel}>{t('cmdk.themeLight')}</div>
<div className={styles.itemDetails}>
<div className={styles.itemLabel}>{t('cmdk.themeLight')}</div>
{theme === 'light' && (
<div className={styles.itemDescription}>{t('cmdk.themeCurrent')}</div>
)}
</div>
</div>
</Command.Item>
<Command.Item onSelect={() => handleThemeChange('dark')} value="theme-dark">
<Moon className={styles.icon} />
<div className={styles.itemContent}>
<div className={styles.itemLabel}>{t('cmdk.themeDark')}</div>
<div className={styles.itemDetails}>
<div className={styles.itemLabel}>{t('cmdk.themeDark')}</div>
{theme === 'dark' && (
<div className={styles.itemDescription}>{t('cmdk.themeCurrent')}</div>
)}
</div>
</div>
</Command.Item>
<Command.Item onSelect={() => handleThemeChange('system')} value="theme-system">
<Monitor className={styles.icon} />
<div className={styles.itemContent}>
<div className={styles.itemLabel}>{t('cmdk.themeAuto')}</div>
<div className={styles.itemDetails}>
<div className={styles.itemLabel}>{t('cmdk.themeAuto')}</div>
{theme === 'system' && (
<div className={styles.itemDescription}>{t('cmdk.themeCurrent')}</div>
)}
</div>
</div>
</Command.Item>
</>

View File

@@ -3,7 +3,7 @@
import { Avatar } from '@lobehub/ui';
import { Command } from 'cmdk';
import { CornerDownLeft } from 'lucide-react';
import { memo, useEffect, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
@@ -21,13 +21,19 @@ import CommandInput from './components/CommandInput';
import { styles } from './styles';
import { useCommandMenu } from './useCommandMenu';
const CLOSE_ANIMATION_DURATION = 150;
interface CommandMenuContentProps {
isClosing: boolean;
onClose: () => void;
}
/**
* Inner component that uses the context
*/
const CommandMenuContent = memo(() => {
const CommandMenuContent = memo<CommandMenuContentProps>(({ isClosing, onClose }) => {
const { t } = useTranslation('common');
const {
closeCommandMenu,
handleBack,
handleSendToSelectedAgent,
hasSearch,
@@ -61,10 +67,11 @@ const CommandMenuContent = memo(() => {
}, [page, setSearch]);
return (
<div className={styles.overlay} onClick={closeCommandMenu}>
<div className={styles.overlay} data-closing={isClosing} onClick={onClose}>
<div onClick={(e) => e.stopPropagation()}>
<Command
className={styles.commandRoot}
data-closing={isClosing}
onKeyDown={(e) => {
// Enter key to send message to selected agent
if (e.key === 'Enter' && selectedAgent && search.trim()) {
@@ -86,7 +93,7 @@ const CommandMenuContent = memo(() => {
} else if (pages.length > 0) {
handleBack();
} else {
closeCommandMenu();
onClose();
}
}
// Backspace clears selected agent when search is empty, or goes to previous page
@@ -145,7 +152,7 @@ const CommandMenuContent = memo(() => {
{!page && !selectedAgent && hasSearch && !search.trimStart().startsWith('@') && (
<SearchResults
isLoading={isSearching}
onClose={closeCommandMenu}
onClose={onClose}
onSetTypeFilter={setTypeFilter}
results={searchResults}
searchQuery={searchQuery}
@@ -169,9 +176,11 @@ CommandMenuContent.displayName = 'CommandMenuContent';
* Search everything in LobeHub.
*/
const CommandMenu = memo(() => {
const [open] = useGlobalStore((s) => [s.status.showCommandMenu]);
const [open, setOpen] = useGlobalStore((s) => [s.status.showCommandMenu, s.updateSystemStatus]);
const [mounted, setMounted] = useState(false);
const [appRoot, setAppRoot] = useState<HTMLElement | null>(null);
const [isClosing, setIsClosing] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const location = useLocation();
const pathname = location.pathname;
@@ -180,6 +189,14 @@ const CommandMenu = memo(() => {
setMounted(true);
}, []);
// Sync visibility with open state
useEffect(() => {
if (open) {
setIsVisible(true);
setIsClosing(false);
}
}, [open]);
// Find App root node (.ant-app)
useEffect(() => {
if (!mounted) return;
@@ -217,11 +234,21 @@ const CommandMenu = memo(() => {
};
}, [mounted]);
if (!mounted || !open || !appRoot) return null;
const handleClose = useCallback(() => {
if (isClosing) return;
setIsClosing(true);
setTimeout(() => {
setOpen({ showCommandMenu: false });
setIsVisible(false);
setIsClosing(false);
}, CLOSE_ANIMATION_DURATION);
}, [isClosing, setOpen]);
if (!mounted || !isVisible || !appRoot) return null;
return createPortal(
<CommandMenuProvider pathname={pathname}>
<CommandMenuContent />
<CommandMenuProvider onClose={handleClose} pathname={pathname}>
<CommandMenuContent isClosing={isClosing} onClose={handleClose} />
</CommandMenuProvider>,
appRoot,
);

View File

@@ -22,6 +22,28 @@ const fadeIn = keyframes`
}
`;
const slideUp = keyframes`
from {
transform: translateY(0) scale(1);
opacity: 1;
}
to {
transform: translateY(-20px) scale(0.96);
opacity: 0;
}
`;
const fadeOut = keyframes`
from {
opacity: 1;
}
to {
opacity: 0;
}
`;
const pulse = keyframes`
0% {
background-position: 200% 0;
@@ -92,6 +114,10 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({
animation: ${slideDown} 0.12s ease-out;
&[data-closing='true'] {
animation: ${slideUp} 0.15s ease-out forwards;
}
[cmdk-input] {
flex: 1;
@@ -275,6 +301,10 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({
background: ${cssVar.colorBgMask};
animation: ${fadeIn} 0.1s ease-in-out;
&[data-closing='true'] {
animation: ${fadeOut} 0.15s ease-out forwards;
}
`,
skeleton: css`
height: 16px;

View File

@@ -25,9 +25,10 @@ import type { ThemeMode } from './types';
* Shared methods for CommandMenu
*/
export const useCommandMenu = () => {
const [open, setOpen] = useGlobalStore((s) => [s.status.showCommandMenu, s.updateSystemStatus]);
const [open] = useGlobalStore((s) => [s.status.showCommandMenu]);
const {
mounted,
onClose,
search,
setSearch,
pages,
@@ -97,15 +98,15 @@ export const useCommandMenu = () => {
}, [open]);
const closeCommandMenu = useCallback(() => {
setOpen({ showCommandMenu: false });
}, [setOpen]);
onClose();
}, [onClose]);
const handleNavigate = useCallback(
(path: string) => {
navigate(path);
setOpen({ showCommandMenu: false });
onClose();
},
[navigate, setOpen],
[navigate, onClose],
);
const handleExternalLink = useCallback(
@@ -115,17 +116,17 @@ export const useCommandMenu = () => {
} else {
window.open(url, '_blank', 'noopener,noreferrer');
}
setOpen({ showCommandMenu: false });
onClose();
},
[setOpen],
[onClose],
);
const handleThemeChange = useCallback(
(theme: ThemeMode) => {
setTheme(theme);
setOpen({ showCommandMenu: false });
onClose();
},
[setTheme, setOpen],
[setTheme, onClose],
);
const handleAskLobeAI = useCallback(() => {
@@ -133,18 +134,18 @@ export const useCommandMenu = () => {
if (inboxAgentId && search.trim()) {
const message = encodeURIComponent(search.trim());
navigate(`/agent/${inboxAgentId}?message=${message}`);
setOpen({ showCommandMenu: false });
onClose();
}
}, [inboxAgentId, search, navigate, setOpen]);
}, [inboxAgentId, search, navigate, onClose]);
const handleAIPainting = useCallback(() => {
// Navigate to painting page with search as prompt
if (search.trim()) {
const prompt = encodeURIComponent(search.trim());
navigate(`/image?prompt=${prompt}`);
setOpen({ showCommandMenu: false });
onClose();
}
}, [search, navigate, setOpen]);
}, [search, navigate, onClose]);
const handleBack = useCallback(() => {
setPages((prev) => prev.slice(0, -1));
@@ -155,9 +156,9 @@ export const useCommandMenu = () => {
const message = encodeURIComponent(search.trim());
navigate(`/agent/${selectedAgent.id}?message=${message}`);
setSelectedAgent(undefined);
setOpen({ showCommandMenu: false });
onClose();
}
}, [selectedAgent, search, navigate, setSelectedAgent, setOpen]);
}, [selectedAgent, search, navigate, setSelectedAgent, onClose]);
const handleCreateSession = useCallback(async () => {
const result = await createAgent({});
@@ -168,32 +169,32 @@ export const useCommandMenu = () => {
navigate(`/agent/${result.agentId}`);
}
setOpen({ showCommandMenu: false });
}, [createAgent, refreshAgentList, navigate, setOpen]);
onClose();
}, [createAgent, refreshAgentList, navigate, onClose]);
const openNewTopicOrSaveTopic = useChatStore((s) => s.openNewTopicOrSaveTopic);
const handleCreateTopic = useCallback(() => {
openNewTopicOrSaveTopic();
setOpen({ showCommandMenu: false });
}, [openNewTopicOrSaveTopic, setOpen]);
onClose();
}, [openNewTopicOrSaveTopic, onClose]);
const handleCreateLibrary = useCallback(() => {
setOpen({ showCommandMenu: false });
onClose();
openCreateLibraryModal({
onSuccess: (id) => {
navigate(`/resource/library/${id}`);
},
});
}, [setOpen, openCreateLibraryModal, navigate]);
}, [onClose, openCreateLibraryModal, navigate]);
const handleCreatePage = useCallback(async () => {
await createPage();
setOpen({ showCommandMenu: false });
}, [createPage, setOpen]);
onClose();
}, [createPage, onClose]);
const handleCreateAgentTeam = useCallback(() => {
setOpen({ showCommandMenu: false });
onClose();
openGroupWizard({
onCreateCustom: async (selectedAgents) => {
await createGroupWithMembers(selectedAgents);
@@ -202,7 +203,7 @@ export const useCommandMenu = () => {
await createGroupFromTemplate(templateId, selectedMemberTitles);
},
});
}, [setOpen, openGroupWizard, createGroupWithMembers, createGroupFromTemplate]);
}, [onClose, openGroupWizard, createGroupWithMembers, createGroupFromTemplate]);
return {
closeCommandMenu,

View File

@@ -1,4 +1,5 @@
import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
import { isDesktop } from '@lobechat/const';
import type { LucideIcon } from 'lucide-react';
import {
Brain,
@@ -90,16 +91,20 @@ export const CONTEXT_COMMANDS: Record<ContextType, ContextCommand[]> = {
path: '/settings/image',
subPath: 'image',
},
{
icon: EthernetPort,
keywords: ['proxy', 'network', 'connection'],
keywordsKey: 'cmdk.keywords.proxy',
label: 'Proxy',
labelKey: 'tab.proxy',
labelNamespace: 'setting',
path: '/settings/proxy',
subPath: 'proxy',
},
...(isDesktop
? [
{
icon: EthernetPort,
keywords: ['proxy', 'network', 'connection'],
keywordsKey: 'cmdk.keywords.proxy',
label: 'Proxy',
labelKey: 'tab.proxy',
labelNamespace: 'setting' as const,
path: '/settings/proxy',
subPath: 'proxy',
},
]
: []),
{
icon: ChartColumnBigIcon,
keywords: ['stats', 'statistics', 'analytics'],

View File

@@ -253,6 +253,7 @@ export default {
'cmdk.submitIssue': 'Submit Issue',
'cmdk.theme': 'Theme',
'cmdk.themeAuto': 'Auto',
'cmdk.themeCurrent': 'Current',
'cmdk.themeDark': 'Dark',
'cmdk.themeLight': 'Light',
'cmdk.toOpen': 'Open',