diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java
index d80fd006..424a05ad 100644
--- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java
@@ -41,6 +41,9 @@ public class SearchController {
@RequestParam(required = false) Instant timeTo,
@RequestParam(required = false) String correlationId,
@RequestParam(required = false) String text,
+ @RequestParam(required = false) String routeId,
+ @RequestParam(required = false) String agentId,
+ @RequestParam(required = false) String processorType,
@RequestParam(defaultValue = "0") int offset,
@RequestParam(defaultValue = "50") int limit) {
@@ -49,6 +52,7 @@ public class SearchController {
null, null,
correlationId,
text, null, null, null,
+ routeId, agentId, processorType,
offset, limit
);
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchEngine.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchEngine.java
index f8f6e5c5..655793b6 100644
--- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchEngine.java
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchEngine.java
@@ -109,9 +109,23 @@ public class ClickHouseSearchEngine implements SearchEngine {
conditions.add("correlation_id = ?");
params.add(req.correlationId());
}
+ if (req.routeId() != null && !req.routeId().isBlank()) {
+ conditions.add("route_id = ?");
+ params.add(req.routeId());
+ }
+ if (req.agentId() != null && !req.agentId().isBlank()) {
+ conditions.add("agent_id = ?");
+ params.add(req.agentId());
+ }
+ if (req.processorType() != null && !req.processorType().isBlank()) {
+ conditions.add("has(processor_types, ?)");
+ params.add(req.processorType());
+ }
if (req.text() != null && !req.text().isBlank()) {
String pattern = "%" + escapeLike(req.text()) + "%";
- conditions.add("(error_message LIKE ? OR error_stacktrace LIKE ? OR exchange_bodies LIKE ? OR exchange_headers LIKE ?)");
+ conditions.add("(route_id LIKE ? OR agent_id LIKE ? OR error_message LIKE ? OR error_stacktrace LIKE ? OR exchange_bodies LIKE ? OR exchange_headers LIKE ?)");
+ params.add(pattern);
+ params.add(pattern);
params.add(pattern);
params.add(pattern);
params.add(pattern);
diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchRequest.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchRequest.java
index 7aa217c6..a6396b23 100644
--- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchRequest.java
+++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchRequest.java
@@ -18,6 +18,9 @@ import java.time.Instant;
* @param textInBody full-text search scoped to exchange bodies
* @param textInHeaders full-text search scoped to exchange headers
* @param textInErrors full-text search scoped to error messages and stack traces
+ * @param routeId exact match on route_id
+ * @param agentId exact match on agent_id
+ * @param processorType matches processor_types array via has()
* @param offset pagination offset (0-based)
* @param limit page size (default 50, max 500)
*/
@@ -32,6 +35,9 @@ public record SearchRequest(
String textInBody,
String textInHeaders,
String textInErrors,
+ String routeId,
+ String agentId,
+ String processorType,
int offset,
int limit
) {
diff --git a/ui/src/api/schema.d.ts b/ui/src/api/schema.d.ts
index 6147c8b5..77b9a061 100644
--- a/ui/src/api/schema.d.ts
+++ b/ui/src/api/schema.d.ts
@@ -122,6 +122,9 @@ export interface SearchRequest {
textInBody?: string | null;
textInHeaders?: string | null;
textInErrors?: string | null;
+ routeId?: string | null;
+ agentId?: string | null;
+ processorType?: string | null;
offset?: number;
limit?: number;
}
diff --git a/ui/src/components/command-palette/CommandPalette.module.css b/ui/src/components/command-palette/CommandPalette.module.css
new file mode 100644
index 00000000..4a20e0d8
--- /dev/null
+++ b/ui/src/components/command-palette/CommandPalette.module.css
@@ -0,0 +1,489 @@
+/* ── Overlay ── */
+.overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 200;
+ background: rgba(6, 10, 19, 0.75);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ display: flex;
+ justify-content: center;
+ padding-top: 12vh;
+ animation: fadeIn 0.12s ease-out;
+}
+
+[data-theme="light"] .overlay {
+ background: rgba(247, 245, 242, 0.75);
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes slideUp {
+ from { opacity: 0; transform: translateY(16px) scale(0.98); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+
+@keyframes slideInResult {
+ from { opacity: 0; transform: translateY(6px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+/* ── Modal ── */
+.modal {
+ width: 680px;
+ max-height: 520px;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ box-shadow: 0 16px 72px rgba(0, 0, 0, 0.5), 0 0 40px rgba(240, 180, 41, 0.04);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ animation: slideUp 0.18s cubic-bezier(0.16, 1, 0.3, 1);
+ align-self: flex-start;
+}
+
+/* ── Input Area ── */
+.inputWrap {
+ display: flex;
+ align-items: center;
+ padding: 14px 18px;
+ border-bottom: 1px solid var(--border-subtle);
+ gap: 10px;
+}
+
+.searchIcon {
+ width: 20px;
+ height: 20px;
+ color: var(--amber);
+ flex-shrink: 0;
+ filter: drop-shadow(0 0 6px var(--amber-glow));
+}
+
+.chipList {
+ display: flex;
+ gap: 6px;
+ flex-shrink: 0;
+}
+
+.chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ padding: 2px 8px;
+ background: var(--amber-glow);
+ color: var(--amber);
+ font-size: 12px;
+ font-weight: 500;
+ border-radius: 4px;
+ white-space: nowrap;
+ font-family: var(--font-mono);
+}
+
+.chipKey {
+ color: var(--text-muted);
+ font-size: 11px;
+}
+
+.chipRemove {
+ background: none;
+ border: none;
+ color: var(--amber);
+ cursor: pointer;
+ font-size: 14px;
+ line-height: 1;
+ padding: 0 0 0 2px;
+ opacity: 0.5;
+}
+
+.chipRemove:hover {
+ opacity: 1;
+}
+
+.input {
+ flex: 1;
+ background: none;
+ border: none;
+ outline: none;
+ font-size: 16px;
+ font-family: var(--font-body);
+ color: var(--text-primary);
+ caret-color: var(--amber);
+ min-width: 100px;
+}
+
+.input::placeholder {
+ color: var(--text-muted);
+}
+
+.inputHint {
+ font-size: 11px;
+ color: var(--text-muted);
+ display: flex;
+ gap: 4px;
+ align-items: center;
+ flex-shrink: 0;
+}
+
+.kbd {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ padding: 1px 5px;
+ background: var(--bg-raised);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ line-height: 1.5;
+ color: var(--text-muted);
+}
+
+/* ── Scope Tabs ── */
+.scopeTabs {
+ display: flex;
+ padding: 8px 18px 0;
+ gap: 2px;
+ border-bottom: 1px solid var(--border-subtle);
+}
+
+.scopeTab {
+ padding: 6px 12px;
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-muted);
+ border: none;
+ background: none;
+ border-bottom: 2px solid transparent;
+ cursor: pointer;
+ transition: color 0.15s, border-color 0.15s;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.scopeTab:hover {
+ color: var(--text-secondary);
+}
+
+.scopeTabActive {
+ composes: scopeTab;
+ color: var(--amber);
+ border-bottom-color: var(--amber);
+}
+
+.scopeCount {
+ font-size: 10px;
+ padding: 1px 6px;
+ background: var(--bg-raised);
+ border-radius: 10px;
+ font-weight: 600;
+ min-width: 20px;
+ text-align: center;
+}
+
+.scopeTabActive .scopeCount {
+ background: var(--amber-glow);
+ color: var(--amber);
+}
+
+.scopeTabDisabled {
+ composes: scopeTab;
+ opacity: 0.4;
+ cursor: default;
+}
+
+/* ── Results ── */
+.results {
+ flex: 1;
+ overflow-y: auto;
+ padding: 6px 8px;
+ scrollbar-width: thin;
+ scrollbar-color: var(--border) transparent;
+}
+
+.results::-webkit-scrollbar { width: 6px; }
+.results::-webkit-scrollbar-track { background: transparent; }
+.results::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
+
+.groupLabel {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ color: var(--text-muted);
+ padding: 10px 12px 4px;
+}
+
+.resultItem {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 10px 12px;
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: background 0.1s;
+ animation: slideInResult 0.2s ease-out both;
+}
+
+.resultItem:nth-child(2) { animation-delay: 0.03s; }
+.resultItem:nth-child(3) { animation-delay: 0.06s; }
+.resultItem:nth-child(4) { animation-delay: 0.09s; }
+.resultItem:nth-child(5) { animation-delay: 0.12s; }
+
+.resultItem:hover {
+ background: var(--bg-hover);
+}
+
+.resultItemSelected {
+ composes: resultItem;
+ background: var(--amber-glow);
+ outline: 1px solid rgba(240, 180, 41, 0.2);
+}
+
+.resultItemSelected:hover {
+ background: var(--amber-glow);
+}
+
+/* ── Result Icon ── */
+.resultIcon {
+ width: 36px;
+ height: 36px;
+ border-radius: var(--radius-sm);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.resultIcon svg {
+ width: 18px;
+ height: 18px;
+}
+
+.iconExecution {
+ composes: resultIcon;
+ background: rgba(59, 130, 246, 0.12);
+ color: var(--blue);
+}
+
+.iconAgent {
+ composes: resultIcon;
+ background: var(--green-glow);
+ color: var(--green);
+}
+
+.iconError {
+ composes: resultIcon;
+ background: var(--rose-glow);
+ color: var(--rose);
+}
+
+/* ── Result Body ── */
+.resultBody {
+ flex: 1;
+ min-width: 0;
+ padding-top: 1px;
+}
+
+.resultTitle {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--text-primary);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ line-height: 1.3;
+}
+
+.highlight {
+ color: var(--amber);
+ font-weight: 600;
+}
+
+.resultMeta {
+ font-size: 12px;
+ color: var(--text-muted);
+ margin-top: 3px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.sep {
+ width: 3px;
+ height: 3px;
+ border-radius: 50%;
+ background: var(--text-muted);
+ opacity: 0.5;
+ flex-shrink: 0;
+}
+
+/* ── Badges ── */
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 11px;
+ font-weight: 500;
+ padding: 2px 8px;
+ border-radius: 12px;
+ line-height: 1.4;
+ white-space: nowrap;
+}
+
+.badgeCompleted {
+ composes: badge;
+ background: var(--green-glow);
+ color: var(--green);
+}
+
+.badgeFailed {
+ composes: badge;
+ background: var(--rose-glow);
+ color: var(--rose);
+}
+
+.badgeRunning {
+ composes: badge;
+ background: rgba(240, 180, 41, 0.12);
+ color: var(--amber);
+}
+
+.badgeDuration {
+ composes: badge;
+ background: var(--bg-raised);
+ color: var(--text-secondary);
+ font-family: var(--font-mono);
+ font-size: 10.5px;
+}
+
+.badgeRoute {
+ composes: badge;
+ background: rgba(168, 85, 247, 0.1);
+ color: var(--purple);
+ font-family: var(--font-mono);
+ font-size: 10.5px;
+}
+
+.badgeLive {
+ composes: badge;
+ background: var(--green-glow);
+ color: var(--green);
+}
+
+.badgeStale {
+ composes: badge;
+ background: rgba(240, 180, 41, 0.12);
+ color: var(--amber);
+}
+
+.badgeDead {
+ composes: badge;
+ background: var(--rose-glow);
+ color: var(--rose);
+}
+
+.resultRight {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 4px;
+ flex-shrink: 0;
+ padding-top: 2px;
+}
+
+.resultTime {
+ font-size: 11px;
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+ white-space: nowrap;
+}
+
+/* ── Empty / Loading ── */
+.emptyState {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 48px 24px;
+ color: var(--text-muted);
+ gap: 8px;
+}
+
+.emptyIcon {
+ width: 40px;
+ height: 40px;
+ opacity: 0.4;
+}
+
+.emptyText {
+ font-size: 14px;
+}
+
+.emptyHint {
+ font-size: 12px;
+ opacity: 0.6;
+}
+
+.loadingDots {
+ display: flex;
+ gap: 4px;
+ padding: 24px;
+ justify-content: center;
+}
+
+.loadingDot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--text-muted);
+ animation: pulse 1.2s ease-in-out infinite;
+}
+
+.loadingDot:nth-child(2) { animation-delay: 0.2s; }
+.loadingDot:nth-child(3) { animation-delay: 0.4s; }
+
+@keyframes pulse {
+ 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
+ 40% { opacity: 1; transform: scale(1); }
+}
+
+/* ── Footer ── */
+.footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 18px;
+ border-top: 1px solid var(--border-subtle);
+ background: var(--bg-raised);
+ border-radius: 0 0 var(--radius-lg) var(--radius-lg);
+}
+
+.footerHints {
+ display: flex;
+ gap: 16px;
+ font-size: 11px;
+ color: var(--text-muted);
+}
+
+.footerHint {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+
+.footerBrand {
+ font-size: 11px;
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+}
+
+/* ── Responsive ── */
+@media (max-width: 768px) {
+ .modal {
+ width: calc(100vw - 32px);
+ max-height: 70vh;
+ }
+}
diff --git a/ui/src/components/command-palette/CommandPalette.tsx b/ui/src/components/command-palette/CommandPalette.tsx
new file mode 100644
index 00000000..8817321b
--- /dev/null
+++ b/ui/src/components/command-palette/CommandPalette.tsx
@@ -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(
+
{
+ if (e.target === e.currentTarget) {
+ close();
+ reset();
+ }
+ }}>
+
+
,
+ document.body,
+ );
+}
diff --git a/ui/src/components/command-palette/PaletteFooter.tsx b/ui/src/components/command-palette/PaletteFooter.tsx
new file mode 100644
index 00000000..28c6f508
--- /dev/null
+++ b/ui/src/components/command-palette/PaletteFooter.tsx
@@ -0,0 +1,24 @@
+import styles from './CommandPalette.module.css';
+
+export function PaletteFooter() {
+ return (
+
+
+
+ ↑
+ ↓ navigate
+
+
+ ↵ open
+
+
+ tab scope
+
+
+ esc close
+
+
+
cameleer3
+
+ );
+}
diff --git a/ui/src/components/command-palette/PaletteInput.tsx b/ui/src/components/command-palette/PaletteInput.tsx
new file mode 100644
index 00000000..c188ead5
--- /dev/null
+++ b/ui/src/components/command-palette/PaletteInput.tsx
@@ -0,0 +1,72 @@
+import { useRef, useEffect } from 'react';
+import { useCommandPalette } from './use-command-palette';
+import { parseFilterPrefix, checkTrailingFilter } from './utils';
+import styles from './CommandPalette.module.css';
+
+export function PaletteInput() {
+ const { query, filters, setQuery, addFilter, removeLastFilter, removeFilter } =
+ useCommandPalette();
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ inputRef.current?.focus();
+ }, []);
+
+ function handleChange(value: string) {
+ // Check if user typed a filter prefix like "status:failed "
+ const parsed = parseFilterPrefix(value);
+ if (parsed) {
+ addFilter(parsed.filter);
+ setQuery(parsed.remaining);
+ return;
+ }
+ const trailing = checkTrailingFilter(value);
+ if (trailing) {
+ addFilter(trailing);
+ setQuery('');
+ return;
+ }
+ setQuery(value);
+ }
+
+ function handleKeyDown(e: React.KeyboardEvent) {
+ if (e.key === 'Backspace' && query === '' && filters.length > 0) {
+ e.preventDefault();
+ removeLastFilter();
+ }
+ }
+
+ return (
+
+
+ {filters.length > 0 && (
+
+ {filters.map((f, i) => (
+
+ {f.key}:
+ {f.value}
+
+
+ ))}
+
+ )}
+
handleChange(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder={filters.length > 0 ? 'Refine search...' : 'Search executions, agents...'}
+ />
+
+ esc close
+
+
+ );
+}
diff --git a/ui/src/components/command-palette/ResultItem.tsx b/ui/src/components/command-palette/ResultItem.tsx
new file mode 100644
index 00000000..f985ea07
--- /dev/null
+++ b/ui/src/components/command-palette/ResultItem.tsx
@@ -0,0 +1,125 @@
+import type { ExecutionSummary, AgentInstance } from '../../api/schema';
+import type { PaletteResult } from './use-palette-search';
+import { highlightMatch, formatRelativeTime } from './utils';
+import styles from './CommandPalette.module.css';
+
+interface ResultItemProps {
+ result: PaletteResult;
+ selected: boolean;
+ query: string;
+ onClick: () => void;
+}
+
+function HighlightedText({ text, query }: { text: string; query: string }) {
+ const parts = highlightMatch(text, query);
+ return (
+ <>
+ {parts.map((p, i) =>
+ typeof p === 'string' ? (
+ {p}
+ ) : (
+ {p.highlight}
+ ),
+ )}
+ >
+ );
+}
+
+function statusBadgeClass(status: string): string {
+ switch (status.toUpperCase()) {
+ case 'COMPLETED': return styles.badgeCompleted;
+ case 'FAILED': return styles.badgeFailed;
+ case 'RUNNING': return styles.badgeRunning;
+ default: return styles.badge;
+ }
+}
+
+function stateBadgeClass(state: string): string {
+ switch (state) {
+ case 'LIVE': return styles.badgeLive;
+ case 'STALE': return styles.badgeStale;
+ case 'DEAD': return styles.badgeDead;
+ default: return styles.badge;
+ }
+}
+
+function ExecutionResult({ data, query }: { data: ExecutionSummary; query: string }) {
+ const isFailed = data.status === 'FAILED';
+ return (
+ <>
+
+
+
+
+ {data.status}
+ {data.durationMs}ms
+
+
+ {data.agentId}
+
+
+ {data.errorMessage && (
+ <>
+
+
+ {data.errorMessage.slice(0, 60)}
+ {data.errorMessage.length > 60 ? '...' : ''}
+
+ >
+ )}
+
+
+
+ {formatRelativeTime(data.startTime)}
+
+ >
+ );
+}
+
+function AgentResult({ data, query }: { data: AgentInstance; query: string }) {
+ return (
+ <>
+
+
+
+
+ {data.state}
+
+
+ group: {data.group}
+
+ last heartbeat: {formatRelativeTime(data.lastHeartbeat)}
+
+
+
+ Agent
+
+ >
+ );
+}
+
+export function ResultItem({ result, selected, query, onClick }: ResultItemProps) {
+ return (
+
+ {result.type === 'execution' && (
+
+ )}
+ {result.type === 'agent' && (
+
+ )}
+
+ );
+}
diff --git a/ui/src/components/command-palette/ResultsList.tsx b/ui/src/components/command-palette/ResultsList.tsx
new file mode 100644
index 00000000..b721eccb
--- /dev/null
+++ b/ui/src/components/command-palette/ResultsList.tsx
@@ -0,0 +1,97 @@
+import { useRef, useEffect } from 'react';
+import { useCommandPalette } from './use-command-palette';
+import type { PaletteResult } from './use-palette-search';
+import { ResultItem } from './ResultItem';
+import styles from './CommandPalette.module.css';
+
+interface ResultsListProps {
+ results: PaletteResult[];
+ isLoading: boolean;
+ onSelect: (result: PaletteResult) => void;
+}
+
+export function ResultsList({ results, isLoading, onSelect }: ResultsListProps) {
+ const { selectedIndex, query } = useCommandPalette();
+ const listRef = useRef(null);
+
+ useEffect(() => {
+ const el = listRef.current?.querySelector('[data-palette-item].selected, [data-palette-item]:nth-child(' + (selectedIndex + 1) + ')');
+ if (!el) return;
+ const items = listRef.current?.querySelectorAll('[data-palette-item]');
+ items?.[selectedIndex]?.scrollIntoView({ block: 'nearest' });
+ }, [selectedIndex]);
+
+ if (isLoading && results.length === 0) {
+ return (
+
+ );
+ }
+
+ if (results.length === 0) {
+ return (
+
+
+
+
No results found
+
+ Try a different search or use filters like status:failed
+
+
+
+ );
+ }
+
+ // Group results by type
+ const executions = results.filter((r) => r.type === 'execution');
+ const agents = results.filter((r) => r.type === 'agent');
+
+ let globalIndex = 0;
+
+ return (
+
+ {executions.length > 0 && (
+ <>
+
Executions
+ {executions.map((r) => {
+ const idx = globalIndex++;
+ return (
+
onSelect(r)}
+ />
+ );
+ })}
+ >
+ )}
+ {agents.length > 0 && (
+ <>
+ Agents
+ {agents.map((r) => {
+ const idx = globalIndex++;
+ return (
+ onSelect(r)}
+ />
+ );
+ })}
+ >
+ )}
+
+ );
+}
diff --git a/ui/src/components/command-palette/ScopeTabs.tsx b/ui/src/components/command-palette/ScopeTabs.tsx
new file mode 100644
index 00000000..39629f31
--- /dev/null
+++ b/ui/src/components/command-palette/ScopeTabs.tsx
@@ -0,0 +1,39 @@
+import { useCommandPalette, type PaletteScope } from './use-command-palette';
+import styles from './CommandPalette.module.css';
+
+interface ScopeTabsProps {
+ executionCount: number;
+ agentCount: number;
+}
+
+const SCOPES: { key: PaletteScope; label: string; disabled?: boolean }[] = [
+ { key: 'all', label: 'All' },
+ { key: 'executions', label: 'Executions' },
+ { key: 'agents', label: 'Agents' },
+];
+
+export function ScopeTabs({ executionCount, agentCount }: ScopeTabsProps) {
+ const { scope, setScope } = useCommandPalette();
+
+ function getCount(key: PaletteScope): number {
+ if (key === 'all') return executionCount + agentCount;
+ if (key === 'executions') return executionCount;
+ if (key === 'agents') return agentCount;
+ return 0;
+ }
+
+ return (
+
+ {SCOPES.map((s) => (
+
+ ))}
+
+ );
+}
diff --git a/ui/src/components/command-palette/use-command-palette.ts b/ui/src/components/command-palette/use-command-palette.ts
new file mode 100644
index 00000000..71b396bb
--- /dev/null
+++ b/ui/src/components/command-palette/use-command-palette.ts
@@ -0,0 +1,57 @@
+import { create } from 'zustand';
+
+export type PaletteScope = 'all' | 'executions' | 'agents';
+
+export interface PaletteFilter {
+ key: 'status' | 'route' | 'agent' | 'processor';
+ value: string;
+}
+
+interface CommandPaletteState {
+ isOpen: boolean;
+ query: string;
+ scope: PaletteScope;
+ filters: PaletteFilter[];
+ selectedIndex: number;
+
+ open: () => void;
+ close: () => void;
+ setQuery: (q: string) => void;
+ setScope: (s: PaletteScope) => void;
+ addFilter: (f: PaletteFilter) => void;
+ removeLastFilter: () => void;
+ removeFilter: (index: number) => void;
+ setSelectedIndex: (i: number) => void;
+ reset: () => void;
+}
+
+export const useCommandPalette = create((set) => ({
+ isOpen: false,
+ query: '',
+ scope: 'all',
+ filters: [],
+ selectedIndex: 0,
+
+ open: () => set({ isOpen: true }),
+ close: () => set({ isOpen: false, selectedIndex: 0 }),
+ setQuery: (q) => set({ query: q, selectedIndex: 0 }),
+ setScope: (s) => set({ scope: s, selectedIndex: 0 }),
+ addFilter: (f) =>
+ set((state) => ({
+ filters: [...state.filters.filter((x) => x.key !== f.key), f],
+ query: '',
+ selectedIndex: 0,
+ })),
+ removeLastFilter: () =>
+ set((state) => ({
+ filters: state.filters.slice(0, -1),
+ selectedIndex: 0,
+ })),
+ removeFilter: (index) =>
+ set((state) => ({
+ filters: state.filters.filter((_, i) => i !== index),
+ selectedIndex: 0,
+ })),
+ setSelectedIndex: (i) => set({ selectedIndex: i }),
+ reset: () => set({ query: '', scope: 'all', filters: [], selectedIndex: 0 }),
+}));
diff --git a/ui/src/components/command-palette/use-palette-search.ts b/ui/src/components/command-palette/use-palette-search.ts
new file mode 100644
index 00000000..286f228c
--- /dev/null
+++ b/ui/src/components/command-palette/use-palette-search.ts
@@ -0,0 +1,93 @@
+import { useQuery } from '@tanstack/react-query';
+import { api } from '../../api/client';
+import type { ExecutionSummary, AgentInstance } from '../../api/schema';
+import { useCommandPalette, type PaletteScope } from './use-command-palette';
+import { useDebouncedValue } from './utils';
+
+export interface PaletteResult {
+ type: 'execution' | 'agent';
+ id: string;
+ data: ExecutionSummary | AgentInstance;
+}
+
+function isExecutionScope(scope: PaletteScope) {
+ return scope === 'all' || scope === 'executions';
+}
+
+function isAgentScope(scope: PaletteScope) {
+ return scope === 'all' || scope === 'agents';
+}
+
+export function usePaletteSearch() {
+ const { query, scope, filters, isOpen } = useCommandPalette();
+ const debouncedQuery = useDebouncedValue(query, 300);
+
+ const statusFilter = filters.find((f) => f.key === 'status')?.value;
+ const routeFilter = filters.find((f) => f.key === 'route')?.value;
+ const agentFilter = filters.find((f) => f.key === 'agent')?.value;
+ const processorFilter = filters.find((f) => f.key === 'processor')?.value;
+
+ const executionsQuery = useQuery({
+ queryKey: ['palette', 'executions', debouncedQuery, statusFilter, routeFilter, agentFilter, processorFilter],
+ queryFn: async () => {
+ const { data, error } = await api.POST('/search/executions', {
+ body: {
+ text: debouncedQuery || undefined,
+ status: statusFilter || undefined,
+ routeId: routeFilter || undefined,
+ agentId: agentFilter || undefined,
+ processorType: processorFilter || undefined,
+ limit: 10,
+ offset: 0,
+ },
+ });
+ if (error) throw new Error('Search failed');
+ return data!;
+ },
+ enabled: isOpen && isExecutionScope(scope),
+ placeholderData: (prev) => prev,
+ });
+
+ const agentsQuery = useQuery({
+ queryKey: ['agents'],
+ queryFn: async () => {
+ const { data, error } = await api.GET('/agents', {
+ params: { query: {} },
+ });
+ if (error) throw new Error('Failed to load agents');
+ return data!;
+ },
+ enabled: isOpen && isAgentScope(scope),
+ staleTime: 30_000,
+ });
+
+ const executionResults: PaletteResult[] = (executionsQuery.data?.data ?? []).map((e) => ({
+ type: 'execution' as const,
+ id: e.executionId,
+ data: e,
+ }));
+
+ const filteredAgents = (agentsQuery.data ?? []).filter((a) => {
+ if (!debouncedQuery) return true;
+ const q = debouncedQuery.toLowerCase();
+ return a.agentId.toLowerCase().includes(q) || a.group.toLowerCase().includes(q);
+ });
+
+ const agentResults: PaletteResult[] = filteredAgents.slice(0, 10).map((a) => ({
+ type: 'agent' as const,
+ id: a.agentId,
+ data: a,
+ }));
+
+ let results: PaletteResult[] = [];
+ if (scope === 'all') results = [...executionResults, ...agentResults];
+ else if (scope === 'executions') results = executionResults;
+ else if (scope === 'agents') results = agentResults;
+
+ return {
+ results,
+ executionCount: executionsQuery.data?.total ?? 0,
+ agentCount: filteredAgents.length,
+ isLoading: executionsQuery.isFetching || agentsQuery.isFetching,
+ };
+}
diff --git a/ui/src/components/command-palette/utils.ts b/ui/src/components/command-palette/utils.ts
new file mode 100644
index 00000000..1b9eb3cf
--- /dev/null
+++ b/ui/src/components/command-palette/utils.ts
@@ -0,0 +1,91 @@
+import { useState, useEffect } from 'react';
+import type { PaletteFilter } from './use-command-palette';
+
+const FILTER_PREFIXES = ['status:', 'route:', 'agent:', 'processor:'] as const;
+
+type FilterKey = PaletteFilter['key'];
+
+const PREFIX_TO_KEY: Record = {
+ 'status:': 'status',
+ 'route:': 'route',
+ 'agent:': 'agent',
+ 'processor:': 'processor',
+};
+
+export function parseFilterPrefix(
+ input: string,
+): { filter: PaletteFilter; remaining: string } | null {
+ for (const prefix of FILTER_PREFIXES) {
+ if (input.startsWith(prefix)) {
+ const value = input.slice(prefix.length).trim();
+ if (value && value.includes(' ')) {
+ const spaceIdx = value.indexOf(' ');
+ return {
+ filter: { key: PREFIX_TO_KEY[prefix], value: value.slice(0, spaceIdx) },
+ remaining: value.slice(spaceIdx + 1).trim(),
+ };
+ }
+ }
+ }
+ return null;
+}
+
+export function checkTrailingFilter(input: string): PaletteFilter | null {
+ for (const prefix of FILTER_PREFIXES) {
+ if (input.endsWith(' ') && input.trimEnd().length > prefix.length) {
+ const trimmed = input.trimEnd();
+ for (const p of FILTER_PREFIXES) {
+ const idx = trimmed.lastIndexOf(p);
+ if (idx !== -1 && idx === trimmed.length - p.length - (trimmed.length - trimmed.lastIndexOf(p) - p.length)) {
+ // This is getting complex, let's use a simpler approach
+ }
+ }
+ }
+ }
+ // Simple approach: check if last word matches prefix:value pattern
+ const words = input.trimEnd().split(/\s+/);
+ const lastWord = words[words.length - 1];
+ for (const prefix of FILTER_PREFIXES) {
+ if (lastWord.startsWith(prefix) && lastWord.length > prefix.length && input.endsWith(' ')) {
+ return {
+ key: PREFIX_TO_KEY[prefix],
+ value: lastWord.slice(prefix.length),
+ };
+ }
+ }
+ return null;
+}
+
+export function highlightMatch(text: string, query: string): (string | { highlight: string })[] {
+ if (!query) return [text];
+ const lower = text.toLowerCase();
+ const qLower = query.toLowerCase();
+ const idx = lower.indexOf(qLower);
+ if (idx === -1) return [text];
+ return [
+ text.slice(0, idx),
+ { highlight: text.slice(idx, idx + query.length) },
+ text.slice(idx + query.length),
+ ].filter((s) => (typeof s === 'string' ? s.length > 0 : true));
+}
+
+export function useDebouncedValue(value: T, delay: number): T {
+ const [debounced, setDebounced] = useState(value);
+ useEffect(() => {
+ const timer = setTimeout(() => setDebounced(value), delay);
+ return () => clearTimeout(timer);
+ }, [value, delay]);
+ return debounced;
+}
+
+export function formatRelativeTime(iso: string): string {
+ const diff = Date.now() - new Date(iso).getTime();
+ const seconds = Math.floor(diff / 1000);
+ if (seconds < 60) return `${seconds}s ago`;
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ return `${days}d ago`;
+}
diff --git a/ui/src/components/layout/AppShell.tsx b/ui/src/components/layout/AppShell.tsx
index 7fdd9514..2847c9d8 100644
--- a/ui/src/components/layout/AppShell.tsx
+++ b/ui/src/components/layout/AppShell.tsx
@@ -1,5 +1,6 @@
import { Outlet } from 'react-router';
import { TopNav } from './TopNav';
+import { CommandPalette } from '../command-palette/CommandPalette';
import styles from './AppShell.module.css';
export function AppShell() {
@@ -9,6 +10,7 @@ export function AppShell() {
+
>
);
}
diff --git a/ui/src/components/layout/TopNav.module.css b/ui/src/components/layout/TopNav.module.css
index cac7dd1f..2503b35b 100644
--- a/ui/src/components/layout/TopNav.module.css
+++ b/ui/src/components/layout/TopNav.module.css
@@ -61,6 +61,43 @@
gap: 16px;
}
+.searchTrigger {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 5px 12px 5px 10px;
+ background: var(--bg-raised);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text-muted);
+ font-size: 13px;
+ font-family: var(--font-body);
+ cursor: pointer;
+ transition: border-color 0.15s, color 0.15s;
+}
+
+.searchTrigger:hover {
+ border-color: var(--text-muted);
+ color: var(--text-secondary);
+}
+
+.searchTrigger svg {
+ width: 14px;
+ height: 14px;
+ opacity: 0.5;
+}
+
+.kbdKey {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ padding: 1px 5px;
+ background: var(--bg-hover);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ color: var(--text-muted);
+ line-height: 1.4;
+}
+
.envBadge {
font-family: var(--font-mono);
font-size: 11px;
diff --git a/ui/src/components/layout/TopNav.tsx b/ui/src/components/layout/TopNav.tsx
index 89e9c84b..c2659799 100644
--- a/ui/src/components/layout/TopNav.tsx
+++ b/ui/src/components/layout/TopNav.tsx
@@ -1,11 +1,13 @@
import { NavLink } from 'react-router';
import { useThemeStore } from '../../theme/theme-store';
import { useAuthStore } from '../../auth/auth-store';
+import { useCommandPalette } from '../command-palette/use-command-palette';
import styles from './TopNav.module.css';
export function TopNav() {
const { theme, toggle } = useThemeStore();
const { username, logout } = useAuthStore();
+ const openPalette = useCommandPalette((s) => s.open);
return (