@skyhook-io/radar-app 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/components/ConnectionErrorView.tsx +186 -70
  19. package/src/components/ContextSwitcher.tsx +63 -18
  20. package/src/components/DebugOverlay.tsx +5 -3
  21. package/src/components/NamespaceSwitcher.tsx +41 -0
  22. package/src/components/UserMenu.tsx +69 -21
  23. package/src/components/applications/ApplicationsView.tsx +936 -0
  24. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  25. package/src/components/audit/AuditView.tsx +65 -62
  26. package/src/components/compare/CompareViewRoute.tsx +124 -0
  27. package/src/components/compare/useCompareCandidates.ts +27 -0
  28. package/src/components/compare/useCompareLauncher.tsx +79 -0
  29. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  30. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  31. package/src/components/cost/CostTrendChart.tsx +106 -75
  32. package/src/components/cost/CostView.test.ts +12 -0
  33. package/src/components/cost/CostView.tsx +507 -223
  34. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  35. package/src/components/cost/CostViewTabs.tsx +40 -0
  36. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  37. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  38. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  39. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  40. package/src/components/cost/cloud-console.test.ts +39 -0
  41. package/src/components/cost/cloud-console.ts +81 -0
  42. package/src/components/cost/errors.ts +8 -0
  43. package/src/components/cost/format.test.ts +27 -0
  44. package/src/components/cost/format.ts +46 -0
  45. package/src/components/cost/kinds.ts +5 -0
  46. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  47. package/src/components/diagnose/AISettings.tsx +147 -0
  48. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  49. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  50. package/src/components/diagnose/Home.tsx +163 -0
  51. package/src/components/diagnose/InvestigationView.tsx +622 -0
  52. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  53. package/src/components/diagnose/launch.ts +65 -0
  54. package/src/components/diagnose/parts.tsx +1756 -0
  55. package/src/components/dock/BottomDock.tsx +2 -3
  56. package/src/components/dock/DockContext.tsx +1 -0
  57. package/src/components/dock/TerminalTab.tsx +1 -1
  58. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  59. package/src/components/dock/index.ts +1 -1
  60. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  61. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  62. package/src/components/execution/batch-run-actions.test.ts +48 -0
  63. package/src/components/execution/batch-run-actions.ts +24 -0
  64. package/src/components/execution/batch-timeline.test.ts +57 -0
  65. package/src/components/execution/batch-timeline.ts +46 -0
  66. package/src/components/execution/execution-definition.test.ts +208 -0
  67. package/src/components/execution/execution-definition.ts +245 -0
  68. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  69. package/src/components/gitops/GitOpsView.tsx +1042 -0
  70. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  71. package/src/components/helm/ChartBrowser.tsx +87 -31
  72. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  73. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  74. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  75. package/src/components/helm/HelmView.tsx +237 -96
  76. package/src/components/helm/InstallWizard.tsx +94 -38
  77. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  78. package/src/components/helm/OwnedResources.tsx +34 -59
  79. package/src/components/helm/RevisionHistory.tsx +52 -3
  80. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  81. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  82. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  83. package/src/components/helm/ValuesViewer.tsx +49 -53
  84. package/src/components/helm/helm-utils.ts +4 -0
  85. package/src/components/home/ActivitySummary.tsx +4 -1
  86. package/src/components/home/ClusterHealthCard.tsx +56 -42
  87. package/src/components/home/CostCard.tsx +21 -36
  88. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  89. package/src/components/home/HelmSummary.tsx +3 -1
  90. package/src/components/home/HomeView.tsx +339 -105
  91. package/src/components/home/MCPSetupDialog.tsx +29 -87
  92. package/src/components/home/TrafficSummary.tsx +2 -2
  93. package/src/components/home/mcpToolCatalog.ts +333 -0
  94. package/src/components/issues/IssuesPane.tsx +151 -0
  95. package/src/components/logs/LogsViewer.tsx +4 -1
  96. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  97. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  98. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  99. package/src/components/portforward/PortForwardButton.tsx +118 -47
  100. package/src/components/portforward/PortForwardManager.tsx +253 -131
  101. package/src/components/resource/HPACharts.tsx +237 -0
  102. package/src/components/resource/PVCUsageBar.tsx +59 -0
  103. package/src/components/resource/PrometheusCharts.tsx +160 -584
  104. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  105. package/src/components/resource/RestartChart.tsx +133 -0
  106. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  107. package/src/components/resource/RightsizingStrip.tsx +363 -0
  108. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  109. package/src/components/resources/CompositeRenderer.tsx +101 -0
  110. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  111. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  112. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  113. package/src/components/resources/ResourcesView.tsx +194 -17
  114. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  116. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  117. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  118. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  119. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  120. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  121. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  122. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  123. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  124. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  125. package/src/components/resources/renderers/index.ts +2 -0
  126. package/src/components/resources/resource-utils.ts +2 -1
  127. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  128. package/src/components/rightsizing/copy.test.ts +56 -0
  129. package/src/components/rightsizing/model.test.ts +227 -0
  130. package/src/components/rightsizing/model.ts +158 -0
  131. package/src/components/rightsizing/presentation.test.ts +104 -0
  132. package/src/components/rightsizing/presentation.ts +94 -0
  133. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  134. package/src/components/settings/SettingsDialog.tsx +1505 -165
  135. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  136. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  137. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  138. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  139. package/src/components/timeline/TimelineList.tsx +86 -13
  140. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  141. package/src/components/timeline/TimelineView.tsx +873 -24
  142. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  143. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  144. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  145. package/src/components/traffic/TrafficGraph.tsx +42 -24
  146. package/src/components/traffic/TrafficView.tsx +32 -19
  147. package/src/components/ui/CommandPalette.tsx +8 -215
  148. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  149. package/src/components/ui/Markdown.tsx +3 -3
  150. package/src/components/ui/Omnibar.tsx +602 -0
  151. package/src/components/ui/RadarOmnibar.tsx +52 -0
  152. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  153. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  154. package/src/components/ui/UpdateNotification.tsx +48 -36
  155. package/src/components/ui/command-items.ts +178 -0
  156. package/src/components/workload/WorkloadView.tsx +1342 -158
  157. package/src/context/ConnectionContext.tsx +146 -21
  158. package/src/context/DiagnoseCustomization.tsx +93 -0
  159. package/src/context/NavCustomization.tsx +75 -0
  160. package/src/context/TimelineSource.tsx +50 -0
  161. package/src/contexts/CapabilitiesContext.tsx +32 -8
  162. package/src/filter/FilterLocationBridge.tsx +30 -0
  163. package/src/hooks/useClusterLoadState.ts +73 -0
  164. package/src/hooks/useDocumentTitle.ts +25 -0
  165. package/src/hooks/useEventSource.ts +6 -0
  166. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  167. package/src/hooks/useMediaQuery.ts +21 -0
  168. package/src/hooks/useNavRailPinned.ts +46 -0
  169. package/src/hooks/useRecentResources.ts +49 -0
  170. package/src/index.css +162 -1
  171. package/src/index.ts +73 -1
  172. package/src/main.tsx +7 -5
  173. package/src/types/clusterLoadState.ts +33 -0
  174. package/src/types.ts +2 -0
  175. package/src/utils/auditBadges.ts +53 -0
  176. package/src/utils/navigation.ts +64 -1
  177. package/src/components/ui/NamespaceSelector.tsx +0 -436
@@ -0,0 +1,602 @@
1
+ import { useState, useMemo, useRef, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { Search, CornerDownLeft, Loader2, AlertTriangle } from 'lucide-react'
4
+ import { clsx } from 'clsx'
5
+ import { SearchPillInput, type SearchModifier } from '@skyhook-io/k8s-ui'
6
+ import { getResourceIcon } from '../../utils/resource-icons'
7
+ import type { SearchHit, SearchMatchedField } from '../../api/client'
8
+ import { bestScore, type CommandItem } from './command-items'
9
+ import { SearchSyntaxHelp } from './SearchSyntaxHelp'
10
+
11
+ // Minimal recent-resource shape the omnibar renders. Hosts own the storage +
12
+ // per-cluster partitioning behind loadRecents/recordRecent.
13
+ export interface OmnibarRecent {
14
+ kind: string
15
+ group?: string
16
+ namespace?: string
17
+ name: string
18
+ cluster?: string
19
+ clusterName?: string
20
+ }
21
+
22
+ // Search results the host feeds in (it runs its own search hook keyed on the
23
+ // debounced query the omnibar emits via onQueryChange).
24
+ export interface OmnibarSearchResult {
25
+ hits: SearchHit[]
26
+ total?: number
27
+ total_matched?: number
28
+ }
29
+
30
+ // Health → dot color (summaryContext.health is the same vocabulary as the rest
31
+ // of Radar). Kept local + tiny to avoid pulling the full status-tone system.
32
+ function healthDot(health?: string): string | null {
33
+ switch (health) {
34
+ case 'healthy': return 'bg-emerald-500'
35
+ case 'degraded': return 'bg-amber-500'
36
+ case 'unhealthy': return 'bg-red-500'
37
+ case 'unknown': return 'bg-theme-text-tertiary'
38
+ default: return null
39
+ }
40
+ }
41
+
42
+ function escapeRe(s: string): string {
43
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
44
+ }
45
+
46
+ // Wrap matched substrings in a brand-tinted, bold run so the user can see WHY a
47
+ // result matched — including when the match is on the namespace/kind, not the
48
+ // name. Longest tokens first so "staging" wins over a stray "s".
49
+ function highlight(text: string, tokens: string[]): React.ReactNode {
50
+ const toks = [...new Set(tokens.filter(Boolean))].sort((a, b) => b.length - a.length)
51
+ if (!toks.length || !text) return text
52
+ const re = new RegExp(`(${toks.map(escapeRe).join('|')})`, 'ig')
53
+ const parts: React.ReactNode[] = []
54
+ let last = 0
55
+ for (const m of text.matchAll(re)) {
56
+ const i = m.index ?? 0
57
+ if (i > last) parts.push(text.slice(last, i))
58
+ parts.push(<mark key={i} className="bg-transparent font-semibold text-[var(--color-brand)]">{m[0]}</mark>)
59
+ last = i + m[0].length
60
+ }
61
+ if (!parts.length) return text
62
+ if (last < text.length) parts.push(text.slice(last))
63
+ return parts
64
+ }
65
+
66
+ // The query tokens that the search engine recorded as landing on a given field
67
+ // (site), so each displayed field highlights only what actually matched it.
68
+ function tokensForSite(matched: SearchMatchedField[] | undefined, ...sites: string[]): string[] {
69
+ if (!matched) return []
70
+ return matched.filter((m) => sites.includes(m.site)).map((m) => m.token)
71
+ }
72
+
73
+ function useDebounced<T>(value: T, ms: number): T {
74
+ const [v, setV] = useState(value)
75
+ useEffect(() => {
76
+ const t = setTimeout(() => setV(value), ms)
77
+ return () => clearTimeout(t)
78
+ }, [value, ms])
79
+ return v
80
+ }
81
+
82
+ export interface OmnibarHandle {
83
+ focus: () => void
84
+ }
85
+
86
+ export interface OmnibarProps {
87
+ /** Open a resource hit (route-based — sets the URL + opens the drawer/page). */
88
+ onOpenResource: (hit: SearchHit) => void
89
+ /** Command-palette items, already built by the host (Views/Actions/Clusters/…).
90
+ * Scored + grouped internally; the host doesn't rank them. */
91
+ commandItems: CommandItem[]
92
+ /** The host runs its own search keyed on this debounced query; `open` lets it
93
+ * gate the request. */
94
+ onQueryChange?: (query: string, open: boolean) => void
95
+ /** Live search results for the current query (host-provided). */
96
+ searchData?: OmnibarSearchResult
97
+ isFetching?: boolean
98
+ isError?: boolean
99
+ /** True while React Query serves a previous query's data — gates Enter/clicks. */
100
+ isPlaceholderData?: boolean
101
+ /** Bounded modifier value sets to autocomplete (e.g. { ns: [...], kind: [...] }). */
102
+ modifierOptions?: Record<string, string[]>
103
+ /** Namespaces to seed as removable `ns:` pills when the launcher opens empty
104
+ * (reflects the current view scope). */
105
+ seedNamespaces?: string[]
106
+ /** Recently-viewed resources for the empty launcher (host owns storage). */
107
+ loadRecents?: () => OmnibarRecent[]
108
+ recordRecent?: (r: OmnibarRecent) => void
109
+ /** When set, a "See all N results" row appears below the resource hits while
110
+ * searching, handing the full (uncapped) query off to the host's dedicated
111
+ * search surface. Omit to keep the omnibar a pure launcher. */
112
+ onViewAllResults?: (query: string) => void
113
+ /** Empty-launcher content. `true` (default) lists Views + Actions — the
114
+ * cmd-K menu. `false` shows only Actions so recents/search lead and views
115
+ * surface on type (use when the views are already always-visible, e.g. a
116
+ * persistent nav rail). */
117
+ launcherShowsViews?: boolean
118
+ placeholder?: string
119
+ /** `hero` renders a large, centered field for landing surfaces (Home);
120
+ * `default` is the slim top-bar field. */
121
+ size?: 'default' | 'hero'
122
+ /** Focus the field on mount (Home hero — the primary action on the page). */
123
+ autoFocus?: boolean
124
+ }
125
+
126
+ type Row =
127
+ | { id: string; kind: 'resource'; hit: SearchHit; recent?: boolean }
128
+ | { id: string; kind: 'command'; command: CommandItem }
129
+ | { id: string; kind: 'viewAll'; query: string; count: number }
130
+
131
+ const COMMAND_CATEGORY_ORDER = ['Views', 'Resource Kinds', 'Namespaces', 'Clusters', 'Actions']
132
+ const PAGE = 8
133
+ const STRONG_KIND = 100 // exact (150) or prefix (100) kind-name match
134
+
135
+ function pillsToQuery(pills: SearchModifier[]): string {
136
+ return pills.map((p) => `${p.key}:${p.value}`).join(' ')
137
+ }
138
+
139
+ // The omnibar: a persistent search box that IS the ⌘K surface. Typing runs the
140
+ // host's live resource search alongside its command-palette items; modifiers
141
+ // (ns:, kind:, …) become removable pills. Resources lead, commands follow.
142
+ //
143
+ // Injectable: all data — search, commands, recents, modifier options — flows in
144
+ // via props, so Radar standalone (cluster /api/search) and Radar Hub (fleet
145
+ // search) share the same UX. ⌘K focus is wired by the host.
146
+ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
147
+ {
148
+ onOpenResource,
149
+ commandItems,
150
+ onQueryChange,
151
+ searchData,
152
+ isFetching = false,
153
+ isError = false,
154
+ isPlaceholderData = false,
155
+ modifierOptions,
156
+ seedNamespaces,
157
+ loadRecents,
158
+ recordRecent,
159
+ onViewAllResults,
160
+ launcherShowsViews = true,
161
+ placeholder = 'Search resources & commands…',
162
+ size = 'default',
163
+ autoFocus = false,
164
+ },
165
+ ref,
166
+ ) {
167
+ const [text, setText] = useState('')
168
+ const [pills, setPills] = useState<SearchModifier[]>([])
169
+ const [open, setOpen] = useState(false)
170
+ const [suggesting, setSuggesting] = useState(false)
171
+ const inputRef = useRef<HTMLInputElement>(null)
172
+ const containerRef = useRef<HTMLDivElement>(null)
173
+ const panelRef = useRef<HTMLDivElement>(null)
174
+ const listRef = useRef<HTMLDivElement>(null)
175
+ // The dropdown is portaled to <body> (so the header's stacking context can't
176
+ // trap the dim overlay). `centerX` aligns the panel under the input; `top` is
177
+ // the HEADER's bottom (not the input's) so the dim starts cleanly below the
178
+ // whole top bar.
179
+ const [anchor, setAnchor] = useState<{ centerX: number; top: number; width: number } | null>(null)
180
+
181
+ useImperativeHandle(ref, () => ({ focus: () => { inputRef.current?.focus(); inputRef.current?.select() } }), [])
182
+
183
+ // Hero autofocus parks the cursor in the field on mount (Home's primary
184
+ // action) but must NOT pop the dropdown — landing on the page shouldn't dim
185
+ // it behind a command palette. Suppress exactly the programmatic focus; a
186
+ // later user focus opens normally.
187
+ const skipFocusOpen = useRef(false)
188
+ useEffect(() => { if (autoFocus) { skipFocusOpen.current = true; inputRef.current?.focus() } }, [autoFocus])
189
+
190
+ // Reflect the current view scope as an editable `ns:` pill on open, so a
191
+ // deliberately broad ⌘K search shows (and lets you remove) the namespace it's
192
+ // narrowed to. Seeded once per open, only from a truly empty launcher state.
193
+ const seededRef = useRef(false)
194
+ useEffect(() => {
195
+ if (!open) { seededRef.current = false; return }
196
+ if (seededRef.current || seedNamespaces === undefined) return
197
+ seededRef.current = true
198
+ if (pills.length === 0 && text === '' && seedNamespaces.length > 0) {
199
+ setPills(seedNamespaces.map((ns) => ({ key: 'ns', value: ns })))
200
+ }
201
+ }, [open, seedNamespaces, pills.length, text])
202
+
203
+ const freeText = text.trim()
204
+ const queryString = useMemo(() => [pillsToQuery(pills), freeText].filter(Boolean).join(' '), [pills, freeText])
205
+ const searchActive = queryString.length >= 2
206
+ // Small debounce: coalesce fast keystrokes (less list reshuffle). The host's
207
+ // search hook handles smoothness (keepPreviousData + AbortSignal).
208
+ const debounced = useDebounced(queryString, 120)
209
+
210
+ // Tell the host which (debounced) query to search, and whether the surface is
211
+ // open (so it can gate the request).
212
+ useEffect(() => { onQueryChange?.(debounced, open) }, [debounced, open, onQueryChange])
213
+
214
+ // Commands score against the FREE text only — modifiers live in pills, so the
215
+ // launcher never sees "ns:" polluting a "go to topology" match. With pills but
216
+ // no text the user is browsing a scope, so suppress the command default. Empty
217
+ // + no pills → the launcher default: Views + Actions, or (when the host opts
218
+ // into a lean launcher) Actions only, so recents/search lead and the views
219
+ // surface on type instead of walling the dropdown.
220
+ const scoredCommands = useMemo(() => {
221
+ if (!freeText) {
222
+ if (pills.length) return []
223
+ const cats = launcherShowsViews ? ['Views', 'Actions'] : ['Actions']
224
+ return commandItems.filter((i) => cats.includes(i.category)).map((item) => ({ item, score: 1 }))
225
+ }
226
+ return commandItems.map((item) => ({ item, score: bestScore(item, freeText) })).filter((x) => x.score > 0).sort((a, b) => b.score - a.score)
227
+ }, [commandItems, freeText, pills.length, launcherShowsViews])
228
+
229
+ // Kinds whose NAME strongly matches (exact 150 / prefix 100) lead ABOVE the
230
+ // resource instances.
231
+ const leadingKinds = useMemo<CommandItem[]>(
232
+ () => (freeText.length < 2 ? [] : scoredCommands.filter((x) => x.item.category === 'Resource Kinds' && x.score >= STRONG_KIND).slice(0, 5).map((x) => x.item)),
233
+ [scoredCommands, freeText],
234
+ )
235
+ const leadingIds = useMemo(() => new Set(leadingKinds.map((i) => i.id)), [leadingKinds])
236
+
237
+ const resourceRows = useMemo<Row[]>(() => {
238
+ const hits = searchData?.hits ?? []
239
+ return hits.map((hit) => ({ id: `res:${hit.cluster || ''}:${hit.kind}:${hit.group || ''}:${hit.namespace || ''}:${hit.name}`, kind: 'resource' as const, hit }))
240
+ }, [searchData])
241
+
242
+ // Launcher recents: only in the truly-empty state (no text, no pills).
243
+ const recentRows = useMemo<Row[]>(() => {
244
+ if (!open || freeText || pills.length > 0 || !loadRecents) return []
245
+ return loadRecents().map((r) => ({
246
+ id: `recent:${r.cluster || ''}:${r.kind}:${r.group || ''}:${r.namespace || ''}:${r.name}`,
247
+ kind: 'resource' as const,
248
+ recent: true,
249
+ hit: { score: 0, kind: r.kind, group: r.group, namespace: r.namespace, name: r.name, cluster: r.cluster, clusterName: r.clusterName } as SearchHit,
250
+ }))
251
+ }, [open, freeText, pills.length, loadRecents])
252
+
253
+ // Remaining matched commands (leading kinds removed so they don't repeat),
254
+ // grouped by their real category in a fixed order. The empty launcher IS the
255
+ // command menu, so show it in full; while searching, cap so resource hits stay
256
+ // prominent.
257
+ const commandGroups = useMemo(() => {
258
+ const launcher = !freeText && pills.length === 0
259
+ const filtered = scoredCommands.filter((x) => !leadingIds.has(x.item.id))
260
+ const rest = (launcher ? filtered : filtered.slice(0, 8)).map((x) => x.item)
261
+ const byCat = new Map<string, CommandItem[]>()
262
+ for (const c of rest) { const l = byCat.get(c.category) ?? []; l.push(c); byCat.set(c.category, l) }
263
+ // `CommandItem.category` is an open string, so a host can use one we don't
264
+ // rank — render those AFTER the known order rather than silently dropping
265
+ // their commands.
266
+ const known = COMMAND_CATEGORY_ORDER.filter((cat) => byCat.has(cat))
267
+ const extra = [...byCat.keys()].filter((cat) => !COMMAND_CATEGORY_ORDER.includes(cat))
268
+ return [...known, ...extra].map((cat) => ({ category: cat, items: byCat.get(cat)! }))
269
+ }, [scoredCommands, leadingIds, freeText, pills.length])
270
+
271
+ const toCmdRow = (c: CommandItem): Row => ({ id: `cmd:${c.id}`, kind: 'command', command: c })
272
+
273
+ const queryTokens = useMemo(() => freeText.split(/\s+/).filter(Boolean), [freeText])
274
+
275
+ // Ordered, id-stable list (render order == keyboard model).
276
+ const rows = useMemo<Row[]>(() => {
277
+ const cmds: Row[] = commandGroups.flatMap((g) => g.items.map(toCmdRow))
278
+ if (!freeText && pills.length === 0) return [...recentRows, ...cmds]
279
+ const out: Row[] = [...leadingKinds.map(toCmdRow), ...(searchActive ? resourceRows : []), ...cmds]
280
+ // "See all results" tails the list when the host wired a full-search surface
281
+ // and there's something to expand to.
282
+ if (onViewAllResults && searchActive && resourceRows.length > 0) {
283
+ out.push({ id: 'view-all', kind: 'viewAll', query: queryString, count: searchData?.total_matched ?? resourceRows.length })
284
+ }
285
+ return out
286
+ }, [recentRows, leadingKinds, resourceRows, commandGroups, freeText, pills.length, searchActive, onViewAllResults, queryString, searchData])
287
+ const viewAllRow = rows.find((r): r is Extract<Row, { kind: 'viewAll' }> => r.kind === 'viewAll')
288
+
289
+ // Selection tracked by stable id (not array index) so Enter can never fire a
290
+ // stale row when the set shifts. Auto-follows the TOP result until the user
291
+ // arrow-keys; a new query re-enables auto-follow.
292
+ // When the host wires a search page (onViewAllResults), Enter on an
293
+ // un-touched query goes THERE with the query rather than firing the top hit —
294
+ // so we never pre-select a row (the user opts into a specific result by
295
+ // arrowing/hovering). Without a search page (OSS), keep auto-follow-top so
296
+ // Enter still opens the best match.
297
+ const submitToSearch = !!onViewAllResults
298
+ const [selectedId, setSelectedId] = useState<string | null>(null)
299
+ const userMovedRef = useRef(false)
300
+ useEffect(() => { userMovedRef.current = false }, [queryString])
301
+ const rowsKey = rows.map((r) => r.id).join('|')
302
+ useEffect(() => {
303
+ // Only suppress the pre-selection while actively SEARCHING (so Enter goes to
304
+ // the search page, not a maybe-wrong top hit). In the empty launcher there's
305
+ // no search to defer to, so auto-select the first row — otherwise Enter is a
306
+ // no-op while the footer still reads "open".
307
+ const dflt = submitToSearch && searchActive ? null : (rows[0]?.id ?? null)
308
+ setSelectedId((cur) => {
309
+ if (!userMovedRef.current) return dflt
310
+ return cur && rows.some((r) => r.id === cur) ? cur : dflt
311
+ })
312
+ // eslint-disable-next-line react-hooks/exhaustive-deps
313
+ }, [rowsKey])
314
+ const selectedIndex = rows.findIndex((r) => r.id === selectedId)
315
+ const moveSelection = (delta: number) => {
316
+ userMovedRef.current = true
317
+ setSelectedId(rows[Math.min(Math.max(selectedIndex + delta, 0), rows.length - 1)]?.id ?? null)
318
+ }
319
+ const selectRow = (id: string) => { userMovedRef.current = true; setSelectedId(id) }
320
+ // Page by a full screenful of visible rows (minus one for context overlap).
321
+ const pageStep = () => {
322
+ const list = listRef.current
323
+ const rowH = (list?.querySelector('button') as HTMLElement | null)?.offsetHeight
324
+ if (!list || !rowH) return PAGE
325
+ return Math.max(1, Math.floor(list.clientHeight / rowH) - 1)
326
+ }
327
+
328
+ const execute = useCallback((row: Row) => {
329
+ if (row.kind === 'command') {
330
+ row.command.action()
331
+ } else if (row.kind === 'viewAll') {
332
+ onViewAllResults?.(row.query)
333
+ } else {
334
+ const h = row.hit
335
+ recordRecent?.({ kind: h.kind, group: h.group, namespace: h.namespace, name: h.name, cluster: h.cluster, clusterName: h.clusterName })
336
+ onOpenResource(h)
337
+ }
338
+ setOpen(false)
339
+ setText('')
340
+ setPills([])
341
+ inputRef.current?.blur()
342
+ }, [onOpenResource, recordRecent, onViewAllResults])
343
+
344
+ // The resources shown don't (yet) belong to the current query: the debounce
345
+ // hasn't fired, the data is React Query placeholder, or results haven't landed.
346
+ const resourcesStale = searchActive && (debounced !== queryString || isPlaceholderData || (resourceRows.length === 0 && isFetching))
347
+
348
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
349
+ if (e.key === 'Escape') { e.preventDefault(); setOpen(false); inputRef.current?.blur(); return }
350
+ if (e.key === 'ArrowDown') { e.preventDefault(); moveSelection(1) }
351
+ else if (e.key === 'ArrowUp') { e.preventDefault(); moveSelection(-1) }
352
+ else if (e.key === 'PageDown') { e.preventDefault(); moveSelection(pageStep()) }
353
+ else if (e.key === 'PageUp') { e.preventDefault(); moveSelection(-pageStep()) }
354
+ else if (e.key === 'Enter') {
355
+ e.preventDefault()
356
+ const row = rows[selectedIndex]
357
+ if (row) {
358
+ if (row.kind === 'resource' && resourcesStale) return
359
+ execute(row)
360
+ return
361
+ }
362
+ // No row chosen: submit the query to the full search page (the default
363
+ // for a host that wired one). viewAllRow carries the count when results
364
+ // are in; fall back to the raw query while they're still loading.
365
+ if (submitToSearch && searchActive) {
366
+ if (viewAllRow) execute(viewAllRow)
367
+ else { onViewAllResults?.(queryString); setOpen(false); setText(''); setPills([]); inputRef.current?.blur() }
368
+ }
369
+ }
370
+ // eslint-disable-next-line react-hooks/exhaustive-deps
371
+ }, [rows, selectedIndex, execute, resourcesStale, submitToSearch, searchActive, viewAllRow, onViewAllResults, queryString])
372
+
373
+ useEffect(() => {
374
+ listRef.current?.querySelector('[data-selected="true"]')?.scrollIntoView({ block: 'nearest' })
375
+ }, [selectedId])
376
+
377
+ // Close on outside click — the panel is portaled out of the container.
378
+ useEffect(() => {
379
+ if (!open) return
380
+ const onDown = (e: MouseEvent) => {
381
+ const t = e.target as Node
382
+ if (!containerRef.current?.contains(t) && !panelRef.current?.contains(t)) setOpen(false)
383
+ }
384
+ document.addEventListener('mousedown', onDown)
385
+ return () => document.removeEventListener('mousedown', onDown)
386
+ }, [open])
387
+
388
+ // Track the input's position so the portaled panel stays anchored under it.
389
+ useEffect(() => {
390
+ if (!open) { setAnchor(null); return }
391
+ const update = () => {
392
+ const el = containerRef.current
393
+ if (!el) return
394
+ const r = el.getBoundingClientRect()
395
+ const header = el.closest('header')
396
+ setAnchor({ centerX: r.left + r.width / 2, top: header ? header.getBoundingClientRect().bottom : r.bottom, width: r.width })
397
+ }
398
+ update()
399
+ window.addEventListener('resize', update)
400
+ window.addEventListener('scroll', update, true)
401
+ return () => { window.removeEventListener('resize', update); window.removeEventListener('scroll', update, true) }
402
+ }, [open])
403
+
404
+ const mac = typeof navigator !== 'undefined' && navigator.platform.includes('Mac')
405
+ const total = searchData?.total ?? 0
406
+ const totalMatched = searchData?.total_matched ?? 0
407
+ const hasNsPill = pills.some((p) => p.key === 'ns')
408
+ const dropdownOpen = open && !suggesting && (rows.length > 0 || searchActive)
409
+
410
+ const clearNsPills = () => { setPills((prev) => prev.filter((p) => p.key !== 'ns')); inputRef.current?.focus() }
411
+
412
+ const hero = size === 'hero'
413
+
414
+ return (
415
+ <div
416
+ ref={containerRef}
417
+ className={clsx('relative w-full', hero ? 'max-w-3xl' : 'max-w-lg', open && hero && 'z-[16]')}
418
+ // Open on click even when the field is already focused — onFocus alone
419
+ // never fires again, so an autofocused hero (Home) wouldn't reveal the
420
+ // launcher on a click.
421
+ onMouseDown={() => setOpen(true)}
422
+ >
423
+ <SearchPillInput
424
+ className={hero
425
+ ? 'min-h-14 px-5 rounded-2xl bg-theme-surface border border-theme-border shadow-theme-sm transition-colors focus-within:border-[var(--color-brand-500)] focus-within:shadow-[0_0_0_4px_color-mix(in_srgb,var(--color-brand-500)_15%,transparent)]'
426
+ : 'min-h-8 px-2.5 rounded-md bg-theme-elevated border border-transparent focus-within:border-theme-border focus-within:bg-theme-surface transition-colors'}
427
+ inputClassName={hero ? 'text-lg py-4' : undefined}
428
+ text={text}
429
+ pills={pills}
430
+ onChange={({ text: t, pills: p }) => { setText(t); setPills(p); setOpen(true) }}
431
+ onKeyDown={handleKeyDown}
432
+ onFocus={() => { if (skipFocusOpen.current) { skipFocusOpen.current = false; return } setOpen(true) }}
433
+ onSuggestingChange={setSuggesting}
434
+ modifierOptions={modifierOptions}
435
+ placeholder={placeholder}
436
+ aria-label="Search resources and commands"
437
+ inputRef={inputRef}
438
+ leftSlot={<Search className={hero ? 'w-5 h-5 shrink-0 text-theme-text-tertiary' : 'w-3.5 h-3.5 shrink-0 text-theme-text-tertiary'} />}
439
+ rightSlot={
440
+ <div className="flex items-center gap-1.5 shrink-0">
441
+ <SearchSyntaxHelp />
442
+ {!hero && !text && pills.length === 0 && (
443
+ <kbd className="text-[10px] text-theme-text-tertiary bg-theme-surface px-1 py-0.5 rounded border border-theme-border-light">
444
+ {mac ? '⌘' : 'Ctrl+'}K
445
+ </kbd>
446
+ )}
447
+ </div>
448
+ }
449
+ />
450
+
451
+ {open && anchor && (dropdownOpen || suggesting) && createPortal(
452
+ <>
453
+ {/* Scrim — separates the dropdown from the page, consistently in both
454
+ modes. At z-[15] it sits BELOW the rail/top bar (z-20/30), so the
455
+ nav chrome stays lit while the content behind the panel dims+blurs:
456
+ a "spotlight on search", not a full-screen modal dim (which fits a
457
+ centered command palette, not an anchored omnibar). The hero covers
458
+ from the top (its box is in the content, lifted to z-[16]); the
459
+ top-bar launcher covers from below the field (its box is already in
460
+ the z-20 chrome). Click closes. */}
461
+ <div
462
+ className="fixed left-0 right-0 bottom-0 z-[15] bg-black/15 dark:bg-black/50 backdrop-blur-[3px]"
463
+ style={{ top: hero ? 0 : anchor.top }}
464
+ onClick={() => { setOpen(false); inputRef.current?.blur() }}
465
+ />
466
+ {dropdownOpen && (
467
+ <div
468
+ ref={panelRef}
469
+ style={{ position: 'fixed', top: anchor.top + 8, left: anchor.centerX, transform: 'translateX(-50%)', width: hero ? Math.round(anchor.width) : 640, maxWidth: 'calc(100vw - 2rem)' }}
470
+ className="z-[121] dialog shadow-theme-lg ring-1 ring-black/5 dark:ring-white/10 overflow-hidden"
471
+ >
472
+ <div ref={listRef} className="max-h-[60vh] overflow-y-auto py-1">
473
+ {recentRows.length > 0 && (
474
+ <div>
475
+ <div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary">Recently viewed</div>
476
+ {recentRows.map((row) => row.kind === 'resource' && (
477
+ <ResourceRow key={row.id} hit={row.hit} selected={row.id === selectedId} onSelect={() => selectRow(row.id)} onActivate={() => execute(row)} />
478
+ ))}
479
+ </div>
480
+ )}
481
+
482
+ {leadingKinds.length > 0 && (
483
+ <div>
484
+ <div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary">Resource Kinds</div>
485
+ {leadingKinds.map((item) => {
486
+ const id = `cmd:${item.id}`
487
+ return <CommandRow key={id} item={item} tokens={queryTokens} selected={id === selectedId} onSelect={() => selectRow(id)} onActivate={() => execute(toCmdRow(item))} />
488
+ })}
489
+ </div>
490
+ )}
491
+
492
+ {searchActive && (
493
+ <>
494
+ <div className="flex items-center justify-between px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary">
495
+ <span>Resources</span>
496
+ {isFetching && <Loader2 className="w-3 h-3 animate-spin" />}
497
+ {!isFetching && !isError && totalMatched > total && <span className="normal-case font-normal">showing {total} of {totalMatched}</span>}
498
+ </div>
499
+ {isError ? (
500
+ <div className="flex items-center gap-2 px-3 py-2 text-xs text-amber-600 dark:text-amber-400">
501
+ <AlertTriangle className="w-3.5 h-3.5 shrink-0" /> Search is unavailable right now.
502
+ </div>
503
+ ) : resourceRows.length === 0 && !isFetching ? (
504
+ <div className="px-3 py-2 text-xs text-theme-text-tertiary">
505
+ No resources match{freeText ? <> “{freeText}”</> : ''}.
506
+ {hasNsPill && (
507
+ <button onMouseDown={(e) => { e.preventDefault(); clearNsPills() }} className="ml-1.5 text-[var(--color-brand)] hover:underline">
508
+ Search all namespaces
509
+ </button>
510
+ )}
511
+ </div>
512
+ ) : (
513
+ resourceRows.map((row) => row.kind === 'resource' && (
514
+ <ResourceRow key={row.id} hit={row.hit} stale={resourcesStale} selected={row.id === selectedId} onSelect={() => selectRow(row.id)} onActivate={() => { if (!resourcesStale) execute(row) }} />
515
+ ))
516
+ )}
517
+ </>
518
+ )}
519
+
520
+ {commandGroups.map((group) => (
521
+ <div key={group.category}>
522
+ <div className="px-3 py-1 mt-1 text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary">{group.category}</div>
523
+ {group.items.map((item) => {
524
+ const id = `cmd:${item.id}`
525
+ return <CommandRow key={id} item={item} tokens={queryTokens} selected={id === selectedId} onSelect={() => selectRow(id)} onActivate={() => execute({ id, kind: 'command', command: item })} />
526
+ })}
527
+ </div>
528
+ ))}
529
+
530
+ {viewAllRow && (
531
+ <button
532
+ type="button"
533
+ data-selected={viewAllRow.id === selectedId}
534
+ onMouseEnter={() => selectRow(viewAllRow.id)}
535
+ onMouseDown={(e) => { e.preventDefault(); execute(viewAllRow) }}
536
+ className={clsx('w-full flex items-center gap-2.5 px-3 py-1.5 mt-1 text-left border-t border-theme-border transition-colors', viewAllRow.id === selectedId ? 'selection' : 'hover:bg-theme-elevated/40')}
537
+ >
538
+ <Search className="w-4 h-4 shrink-0 text-theme-text-tertiary" />
539
+ <span className="text-sm text-[var(--color-brand)]">See all {viewAllRow.count} result{viewAllRow.count === 1 ? '' : 's'}</span>
540
+ <CornerDownLeft className="w-3 h-3 ml-auto shrink-0 text-theme-text-tertiary" />
541
+ </button>
542
+ )}
543
+ </div>
544
+ <div className="flex items-center gap-3 px-3 py-1.5 border-t border-theme-border text-[11px] text-theme-text-tertiary">
545
+ <span className="flex items-center gap-1">
546
+ <CornerDownLeft className="w-3 h-3" /> {submitToSearch && searchActive && selectedIndex < 0 ? 'search all' : 'open'}
547
+ </span>
548
+ <span>↑↓ navigate</span>
549
+ <span>⇞⇟ page</span>
550
+ <span>esc close</span>
551
+ </div>
552
+ </div>
553
+ )}
554
+ </>,
555
+ document.body,
556
+ )}
557
+ </div>
558
+ )
559
+ })
560
+
561
+ function ResourceRow({ hit, selected, stale, onSelect, onActivate }: { hit: SearchHit; selected: boolean; stale?: boolean; onSelect: () => void; onActivate: () => void }) {
562
+ const Icon = getResourceIcon(hit.kind)
563
+ const dot = healthDot(hit.summaryContext?.health)
564
+ const issues = hit.summaryContext?.issueCount ?? 0
565
+ const contentOnly = !!hit.matched?.length && hit.matched.every((m) => m.site.startsWith('content:'))
566
+ return (
567
+ <button
568
+ data-selected={selected}
569
+ onClick={onActivate}
570
+ onMouseMove={onSelect}
571
+ className={clsx('w-full flex items-center gap-2.5 px-3 py-1.5 text-left transition-colors', selected ? 'selection' : 'hover:bg-theme-elevated/40', stale && 'opacity-50')}
572
+ >
573
+ <Icon className="w-4 h-4 shrink-0 text-theme-text-tertiary" />
574
+ <span className="min-w-0 truncate text-sm text-theme-text-primary">{highlight(hit.name, tokensForSite(hit.matched, 'name'))}</span>
575
+ {dot && <span className={clsx('h-1.5 w-1.5 rounded-full shrink-0', dot)} />}
576
+ <span className="shrink-0 max-w-[45%] truncate text-xs text-theme-text-tertiary">
577
+ {highlight(hit.kind, tokensForSite(hit.matched, 'kind'))}
578
+ {hit.namespace ? <> · {highlight(hit.namespace, tokensForSite(hit.matched, 'namespace'))}</> : ''}
579
+ {hit.clusterName ? <> · <span className="text-theme-text-secondary">{hit.clusterName}</span></> : ''}
580
+ </span>
581
+ {contentOnly && <span className="shrink-0 text-[10px] text-theme-text-tertiary italic">in spec</span>}
582
+ {issues > 0 && <span className="ml-auto shrink-0 text-[10px] font-medium text-amber-600 dark:text-amber-400">{issues} issue{issues === 1 ? '' : 's'}</span>}
583
+ </button>
584
+ )
585
+ }
586
+
587
+ function CommandRow({ item, tokens, selected, onSelect, onActivate }: { item: CommandItem; tokens: string[]; selected: boolean; onSelect: () => void; onActivate: () => void }) {
588
+ const Icon = item.icon
589
+ return (
590
+ <button
591
+ data-selected={selected}
592
+ onClick={onActivate}
593
+ onMouseMove={onSelect}
594
+ className={clsx('w-full flex items-center gap-2.5 px-3 py-1.5 text-left transition-colors', selected ? 'selection' : 'hover:bg-theme-elevated/40')}
595
+ >
596
+ {Icon ? <Icon className="w-4 h-4 shrink-0 text-theme-text-tertiary" /> : <span className="w-4 shrink-0" />}
597
+ <span className="min-w-0 truncate text-sm text-theme-text-primary">{highlight(item.label, tokens)}</span>
598
+ {item.sublabel && <span className="shrink-0 max-w-[45%] truncate text-xs text-theme-text-tertiary">{highlight(item.sublabel, tokens)}</span>}
599
+ {item.shortcut && <kbd className="ml-auto shrink-0 text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 py-0.5 rounded border border-theme-border-light">{item.shortcut}</kbd>}
600
+ </button>
601
+ )
602
+ }
@@ -0,0 +1,52 @@
1
+ import { forwardRef, useMemo, useState } from 'react'
2
+ import { Omnibar, type OmnibarHandle } from './Omnibar'
3
+ import { useSearch, useNamespaceScope, useContexts, type SearchHit } from '../../api/client'
4
+ import { useAPIResources } from '../../api/apiResources'
5
+ import { loadRecentResources, recordRecentResource } from '../../hooks/useRecentResources'
6
+ import { useCommandItems, type CommandItemCallbacks } from './command-items'
7
+
8
+ interface RadarOmnibarProps extends CommandItemCallbacks {
9
+ onOpenResource: (hit: SearchHit) => void
10
+ }
11
+
12
+ // Radar standalone's omnibar: wires the injectable Omnibar to Radar's own hooks
13
+ // (cluster /api/search, kubeconfig contexts, API discovery, recents). Radar Hub
14
+ // provides a parallel wrapper over fleet search.
15
+ export const RadarOmnibar = forwardRef<OmnibarHandle, RadarOmnibarProps>(function RadarOmnibar(
16
+ { onOpenResource, ...callbacks },
17
+ ref,
18
+ ) {
19
+ // The omnibar debounces internally and emits the query to search here.
20
+ const [query, setQuery] = useState('')
21
+ const [open, setOpen] = useState(false)
22
+
23
+ const { data: searchData, isFetching, isPlaceholderData, isError } = useSearch(query, { enabled: open, globalNs: true })
24
+ const { data: nsScope } = useNamespaceScope()
25
+ const { data: apiResources } = useAPIResources()
26
+ const { data: contexts } = useContexts()
27
+ const contextKey = useMemo(() => contexts?.find((c) => c.isCurrent)?.name ?? '', [contexts])
28
+
29
+ const modifierOptions = useMemo(() => ({
30
+ ns: nsScope?.accessibleNamespaces ?? [],
31
+ kind: apiResources ? [...new Set(apiResources.filter((r) => r.verbs?.includes('list')).map((r) => r.kind))].sort() : [],
32
+ }), [nsScope, apiResources])
33
+
34
+ const commandItems = useCommandItems(callbacks)
35
+
36
+ return (
37
+ <Omnibar
38
+ ref={ref}
39
+ onOpenResource={onOpenResource}
40
+ commandItems={commandItems}
41
+ onQueryChange={(q, o) => { setQuery(q); setOpen(o) }}
42
+ searchData={searchData}
43
+ isFetching={isFetching}
44
+ isError={isError}
45
+ isPlaceholderData={isPlaceholderData}
46
+ modifierOptions={modifierOptions}
47
+ seedNamespaces={nsScope?.actives}
48
+ loadRecents={() => loadRecentResources(contextKey)}
49
+ recordRecent={(r) => recordRecentResource(r, contextKey)}
50
+ />
51
+ )
52
+ })