Add Cmd+K command palette for searching executions and agents
Backend: add routeId, agentId, processorType filter fields to SearchRequest and ClickHouseSearchEngine. Expand global text search to match route_id and agent_id columns. Frontend: new command palette component (portal overlay, Zustand store, TanStack Query search hook with 300ms debounce, filter chip parsing, keyboard navigation, scope tabs). Search bar in SearchFilters and TopNav now open the palette. Selecting a result writes filters to the execution search store to drive the results table. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
131
ui/src/components/command-palette/CommandPalette.tsx
Normal file
131
ui/src/components/command-palette/CommandPalette.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useCommandPalette, type PaletteScope } from './use-command-palette';
|
||||
import { usePaletteSearch, type PaletteResult } from './use-palette-search';
|
||||
import { useExecutionSearch } from '../../pages/executions/use-execution-search';
|
||||
import { PaletteInput } from './PaletteInput';
|
||||
import { ScopeTabs } from './ScopeTabs';
|
||||
import { ResultsList } from './ResultsList';
|
||||
import { PaletteFooter } from './PaletteFooter';
|
||||
import type { ExecutionSummary, AgentInstance } from '../../api/schema';
|
||||
import styles from './CommandPalette.module.css';
|
||||
|
||||
const SCOPES: PaletteScope[] = ['all', 'executions', 'agents'];
|
||||
|
||||
export function CommandPalette() {
|
||||
const { isOpen, close, scope, setScope, selectedIndex, setSelectedIndex, reset, filters } =
|
||||
useCommandPalette();
|
||||
const { results, executionCount, agentCount, isLoading } = usePaletteSearch();
|
||||
const execSearch = useExecutionSearch();
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(result: PaletteResult) => {
|
||||
if (result.type === 'execution') {
|
||||
const exec = result.data as ExecutionSummary;
|
||||
execSearch.setText(exec.executionId);
|
||||
execSearch.setRouteId('');
|
||||
execSearch.setAgentId('');
|
||||
execSearch.setProcessorType('');
|
||||
} else if (result.type === 'agent') {
|
||||
const agent = result.data as AgentInstance;
|
||||
execSearch.setAgentId(agent.agentId);
|
||||
execSearch.setText('');
|
||||
execSearch.setRouteId('');
|
||||
execSearch.setProcessorType('');
|
||||
}
|
||||
// Apply any active palette filters to the execution search
|
||||
for (const f of filters) {
|
||||
if (f.key === 'status') execSearch.setStatus([f.value.toUpperCase()]);
|
||||
if (f.key === 'route') execSearch.setRouteId(f.value);
|
||||
if (f.key === 'agent') execSearch.setAgentId(f.value);
|
||||
if (f.key === 'processor') execSearch.setProcessorType(f.value);
|
||||
}
|
||||
close();
|
||||
reset();
|
||||
},
|
||||
[close, reset, execSearch, filters],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (!isOpen) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
close();
|
||||
reset();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(
|
||||
results.length > 0 ? (selectedIndex + 1) % results.length : 0,
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(
|
||||
results.length > 0
|
||||
? (selectedIndex - 1 + results.length) % results.length
|
||||
: 0,
|
||||
);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (results[selectedIndex]) {
|
||||
handleSelect(results[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Tab':
|
||||
e.preventDefault();
|
||||
const idx = SCOPES.indexOf(scope);
|
||||
setScope(SCOPES[(idx + 1) % SCOPES.length]);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[isOpen, close, reset, selectedIndex, setSelectedIndex, results, handleSelect, scope, setScope],
|
||||
);
|
||||
|
||||
// Global Cmd+K / Ctrl+K listener
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
const store = useCommandPalette.getState();
|
||||
if (store.isOpen) {
|
||||
store.close();
|
||||
store.reset();
|
||||
} else {
|
||||
store.open();
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, []);
|
||||
|
||||
// Keyboard handling when open
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className={styles.overlay} onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
close();
|
||||
reset();
|
||||
}
|
||||
}}>
|
||||
<div className={styles.modal}>
|
||||
<PaletteInput />
|
||||
<ScopeTabs executionCount={executionCount} agentCount={agentCount} />
|
||||
<ResultsList results={results} isLoading={isLoading} onSelect={handleSelect} />
|
||||
<PaletteFooter />
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user