src/features/CommandMenu/README.md
The CommandMenu is a powerful command palette feature inspired by tools like Raycast and VS Code's Command Palette. It provides a unified, keyboard-driven interface for quick navigation, searching, and actions across the entire application.
Key Library: Built on top of cmdk (Command K) by Paco Coursey.
document.body for proper z-index layeringCommandMenu/
├── index.tsx # Main component & orchestration
├── useCommandMenu.ts # Core hook with business logic
├── types.ts # TypeScript type definitions
├── styles.ts # antd-style CSS-in-JS styles
│
├── components/
│ ├── CommandInput.tsx # Search input with context/back navigation
│ └── CommandFooter.tsx # Keyboard shortcuts help
│
├── MainMenu.tsx # Default menu (navigation, settings, etc.)
├── ContextCommands.tsx # Context-specific commands
├── SearchResults.tsx # Search result display
├── ChatList.tsx # AI chat mode message list
├── ThemeMenu.tsx # Theme selection submenu
│
└── utils/
├── context.ts # Context detection logic
└── contextCommands.ts # Context command definitions
The CommandMenu automatically detects what page you're on and shows relevant commands.
File: utils/context.ts
// Supported contexts
type ContextType = 'agent' | 'painting' | 'settings' | 'resource' | 'page';
// Context detection based on pathname
const CONTEXT_CONFIGS: ContextConfig[] = [
{ matcher: /^\/agent\/[^/]+$/, name: 'Agent', type: 'agent' },
{ matcher: /^\/image$/, name: 'Painting', type: 'painting' },
{
matcher: /^\/settings(?:\/([^/]+))?/,
name: 'Settings',
type: 'settings',
captureSubPath: true // Captures sub-route like "profile"
},
// ...
];
Example: When on /settings/profile, context is:
{
type: 'settings',
name: 'Settings',
subPath: 'profile'
}
Uses an array-based stack for hierarchical navigation:
// State
const [pages, setPages] = useState<string[]>([]);
const page = pages.at(-1); // Current page
// Navigate to submenu
navigateToPage('theme'); // pages = ['theme']
// Navigate deeper
navigateToPage('ai-chat'); // pages = ['theme', 'ai-chat']
// Go back
handleBack(); // pages = ['theme']
Keyboard Shortcuts:
Escape: Go back one level or close if at rootBackspace: Go back when search is emptySpecial mode for asking AI questions about your work.
Activation: Press Tab when you have search text (and not already in AI mode)
Flow:
Tab'ai-chat'State Management:
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const isAiMode = page === 'ai-chat';
const handleAskAI = () => {
if (search.trim()) {
const userMessage = {
content: search,
id: Date.now().toString(),
role: 'user',
};
setChatMessages(prev => [...prev, userMessage]);
}
setPages([...pages, 'ai-chat']);
};
Backend: Uses tRPC Lambda client to query the search API
File: useCommandMenu.ts:38-51
// Debounced search to reduce API calls
const debouncedSearch = useDebounce(search, { wait: 300 });
// SWR-based search
const { data: searchResults, isLoading: isSearching } = useSWR<SearchResult[]>(
hasSearch && !isAiMode ? ['search', searchQuery] : null,
async () => lambdaClient.search.query.query({ query: searchQuery }),
{ revalidateOnFocus: false, revalidateOnReconnect: false }
);
Search Types:
message: Chat messages (NEW - highest priority in agent context)agent: AI agents/assistantstopic: Conversation topics/threadsfile: Uploaded files/knowledge baseDisplay: Results are grouped by type in SearchResults.tsx with priority order: Messages → Topics → Agents → Files
Context-Aware Priority (NEW):
/agent/*): Messages (10), Topics (5), Agents (3), Files (3)
Commands that appear based on current context (e.g., Settings submenu navigation).
File: utils/contextCommands.ts
// Define commands for each context type
export const CONTEXT_COMMANDS: Record<ContextType, ContextCommand[]> = {
settings: [
{
label: 'Profile',
path: '/settings/profile',
subPath: 'profile',
icon: UserCircle,
keywords: ['profile', 'user', 'account'],
},
// ...more settings pages
],
agent: [], // No context commands for agent pages yet
// ...
};
Smart Filtering: Automatically hides the current page from the list
// If on /settings/profile, won't show "Profile" in context commands
return commands.filter((cmd) => cmd.subPath !== currentSubPath);
User presses Cmd+K
↓
GlobalStore.updateSystemStatus({ showCommandMenu: true })
↓
useCommandMenu hook detects open=true
↓
useEffect resets state (pages=[], search='', chatMessages=[])
↓
CommandMenu renders via portal to document.body
↓
detectContext(pathname) determines current context
↓
Renders appropriate menu based on state:
- No page + no search → MainMenu + ContextCommands
- No page + has search → MainMenu + SearchResults
- page='theme' → ThemeMenu
- page='ai-chat' → ChatList
User types in input
↓
setSearch(value)
↓
useDebounce delays by 300ms
↓
SWR key changes to ['search', query]
↓
lambdaClient.search.query.query({ query })
↓
SearchResults receives results
↓
Groups by type (agents/topics/files)
↓
Renders with type-specific icons and navigation
User selects "Settings" command
↓
handleNavigate('/settings') called
↓
react-router navigate('/settings')
↓
closeCommandMenu() → setOpen({ showCommandMenu: false })
↓
CommandMenu unmounts
| Key | Action |
|---|---|
Cmd/Ctrl + K | Open/Close command menu (global) |
Escape | Go back or close |
Backspace | Go back (when search empty) |
Tab | Enter AI mode (when search has text) |
↑/↓ | Navigate items |
Enter | Select item |
The cmdk library provides built-in fuzzy filtering:
shouldFilter={!isAiMode} (disabled in AI mode)SearchResults.tsx:71-78)useEffect(() => {
if (open) {
const originalStyle = window.getComputedStyle(document.body).overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalStyle;
};
}
}, [open]);
Search results show skeleton loaders while fetching:
if (isLoading) {
return (
<Command.Group heading={t('cmdk.search.searching')}>
{[1, 2, 3].map((i) => (
<div className={styles.skeletonItem} key={i}>
<div className={styles.skeleton} />
</div>
))}
</Command.Group>
);
}
Uses antd-style for theme-aware CSS-in-JS:
Key Patterns:
token.* for colors/spacing to support dark modeExample:
commandRoot: css`
width: min(640px, 90vw);
max-height: min(500px, 70vh);
background: ${token.colorBgElevated};
box-shadow: ${token.boxShadowSecondary};
animation: slide-down 0.12s ease-out;
@keyframes slide-down {
from {
transform: translateY(-20px) scale(0.96);
opacity: 0;
}
to {
transform: translateY(0) scale(1);
opacity: 1;
}
}
`,
// src/store/global
interface SystemStatus {
showCommandMenu: boolean;
// ...
}
// Usage in hook
const [open, setOpen] = useGlobalStore((s) => [
s.status.showCommandMenu,
s.updateSystemStatus
]);
import { useLocation, useNavigate } from 'react-router-dom';
const navigate = useNavigate();
const location = useLocation();
const pathname = location.pathname;
const handleNavigate = (path: string) => {
navigate(path);
closeCommandMenu();
};
import { lambdaClient } from '@/libs/trpc/client';
// Call server-side search function
lambdaClient.search.query.query({ query: searchQuery });
import { useTranslation } from 'react-i18next';
const { t } = useTranslation('common');
// Usage
<Command.Empty>{t('cmdk.noResults')}</Command.Empty>
Translation Keys (in src/locales/default/common.ts):
cmdk.searchPlaceholdercmdk.aiModePlaceholdercmdk.noResultscmdk.newAgentcmdk.settingsStep 1: Add context type to types.ts:
export type ContextType =
| 'agent'
| 'painting'
| 'settings'
| 'resource'
| 'page'
| 'your-new-context'; // Add this
Step 2: Add detection rule to utils/context.ts:
const CONTEXT_CONFIGS: ContextConfig[] = [
// ...existing configs
{
matcher: /^\/your-route/,
name: 'Your Context Name',
type: 'your-new-context',
captureSubPath: true, // optional
},
];
Step 3: Add commands to utils/contextCommands.ts:
export const CONTEXT_COMMANDS: Record<ContextType, ContextCommand[]> = {
// ...
'your-new-context': [
{
label: 'Sub Command',
path: '/your-route/sub',
subPath: 'sub',
icon: YourIcon,
keywords: ['keyword1', 'keyword2'],
},
],
};
Step 1: Create component (e.g., YourMenu.tsx):
import { Command } from 'cmdk';
import { memo } from 'react';
interface YourMenuProps {
onSomething: (value: string) => void;
styles: any;
}
const YourMenu = memo<YourMenuProps>(({ onSomething, styles }) => {
return (
<>
<Command.Item onSelect={() => onSomething('value1')} value="option-1">
<YourIcon className={styles.icon} />
<div className={styles.itemContent}>
<div className={styles.itemLabel}>Option 1</div>
</div>
</Command.Item>
</>
);
});
export default YourMenu;
Step 2: Add handler to useCommandMenu.ts:
const handleYourAction = (value: string) => {
// Do something
closeCommandMenu();
};
return {
// ...
handleYourAction,
};
Step 3: Render in index.tsx:
{page === 'your-page' && (
<YourMenu
onSomething={handleYourAction}
styles={styles}
/>
)}
Step 4: Add navigation to it:
// In MainMenu or elsewhere
<Command.Item onSelect={() => navigateToPage('your-page')}>
Your Page
</Command.Item>
In MainMenu.tsx:
<Command.Item
onSelect={() => onNavigate('/your-route')}
value="your-command keywords here"
>
<YourIcon className={styles.icon} />
<div className={styles.itemContent}>
<div className={styles.itemLabel}>{t('cmdk.yourLabel')}</div>
</div>
</Command.Item>
Remember to:
src/locales/default/common.tslucide-reactSearch is handled server-side via tRPC. To modify:
Backend: Update src/server/routers/lambda/search.ts (or similar)
Frontend: Search results display in SearchResults.tsx
getIcon() for custom iconshandleNavigate() for custom routinggetItemValue() for search ranking<Command.Item
onSelect={() => handleAction()}
value="searchable keywords here"
>
<Icon className={styles.icon} />
<div className={styles.itemContent}>
<div className={styles.itemLabel}>Primary Label</div>
</div>
</Command.Item>
<Command.Group heading={t('cmdk.groupName')}>
<Command.Item>...</Command.Item>
<Command.Item>...</Command.Item>
</Command.Group>
{!pathname?.startsWith('/settings') && (
<Command.Item onSelect={() => onNavigate('/settings')}>
Settings
</Command.Item>
)}
<Command.Item
onSelect={() => onExternalLink('https://example.com')}
>
External Link
</Command.Item>
When testing CommandMenu features:
Context Detection: Test pathname matching
expect(detectContext('/agent/123')).toEqual({ type: 'agent', name: 'Agent' });
Navigation Stack: Test page state management
navigateToPage('theme');
expect(pages).toEqual(['theme']);
handleBack();
expect(pages).toEqual([]);
Search Debouncing: Mock timers or use vi.advanceTimersByTime(300)
Portal Rendering: Use screen.getByRole('dialog') or similar
Keyboard Events: Simulate with fireEvent.keyDown(element, { key: 'Escape' })
memo() to prevent re-rendersopen && mountedPotential areas for enhancement:
Open Menu: Cmd/Ctrl + K
Main Files:
index.tsx - Main orchestrationuseCommandMenu.ts - Business logicMainMenu.tsx - Default commandsSearchResults.tsx - Search displayutils/context.ts - Context detectionutils/contextCommands.ts - Context commandsKey Dependencies:
cmdk - Command palette primitivesreact-router-dom - Navigationzustand - Global stateswr - Data fetchingantd-style - Stylinglucide-react - IconsRelated Documentation: