From 64b03a4e2fb23485d5d3ede7d8b74412755bea96 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:28:16 +0100 Subject: [PATCH] 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 --- .../app/controller/SearchController.java | 4 + .../app/search/ClickHouseSearchEngine.java | 16 +- .../server/core/search/SearchRequest.java | 6 + ui/src/api/schema.d.ts | 3 + .../command-palette/CommandPalette.module.css | 489 ++++++++++++++++++ .../command-palette/CommandPalette.tsx | 131 +++++ .../command-palette/PaletteFooter.tsx | 24 + .../command-palette/PaletteInput.tsx | 72 +++ .../components/command-palette/ResultItem.tsx | 125 +++++ .../command-palette/ResultsList.tsx | 97 ++++ .../components/command-palette/ScopeTabs.tsx | 39 ++ .../command-palette/use-command-palette.ts | 57 ++ .../command-palette/use-palette-search.ts | 93 ++++ ui/src/components/command-palette/utils.ts | 91 ++++ ui/src/components/layout/AppShell.tsx | 2 + ui/src/components/layout/TopNav.module.css | 37 ++ ui/src/components/layout/TopNav.tsx | 10 + .../pages/executions/SearchFilters.module.css | 66 +-- ui/src/pages/executions/SearchFilters.tsx | 35 +- .../pages/executions/use-execution-search.ts | 18 + 20 files changed, 1348 insertions(+), 67 deletions(-) create mode 100644 ui/src/components/command-palette/CommandPalette.module.css create mode 100644 ui/src/components/command-palette/CommandPalette.tsx create mode 100644 ui/src/components/command-palette/PaletteFooter.tsx create mode 100644 ui/src/components/command-palette/PaletteInput.tsx create mode 100644 ui/src/components/command-palette/ResultItem.tsx create mode 100644 ui/src/components/command-palette/ResultsList.tsx create mode 100644 ui/src/components/command-palette/ScopeTabs.tsx create mode 100644 ui/src/components/command-palette/use-command-palette.ts create mode 100644 ui/src/components/command-palette/use-palette-search.ts create mode 100644 ui/src/components/command-palette/utils.ts 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 (