@skyhook-io/radar-app 1.6.0 → 1.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/App.tsx CHANGED
@@ -56,7 +56,8 @@ import { SettingsDialog } from './components/settings/SettingsDialog'
56
56
  import { MyPermissionsDialog } from './components/settings/MyPermissionsDialog'
57
57
  import type { TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
58
58
  import { kindToPlural, openExternal, apiVersionToGroup, buildWorkloadPath, searchHitToSelectedResource } from './utils/navigation'
59
- import { Omnibar, type OmnibarHandle } from './components/ui/Omnibar'
59
+ import { type OmnibarHandle } from './components/ui/Omnibar'
60
+ import { RadarOmnibar } from './components/ui/RadarOmnibar'
60
61
  import type { ContextSwitcherHandle } from './components/ContextSwitcher'
61
62
 
62
63
  // All possible node kinds (core + GitOps)
@@ -541,7 +542,9 @@ function AppInner() {
541
542
  description: 'Show keyboard shortcuts',
542
543
  category: 'General' as const,
543
544
  scope: 'global' as const,
544
- handler: () => setShowHelp(prev => !prev),
545
+ // Chromeless embeds (Radar Hub) own their own help surface — don't open a
546
+ // competing Radar overlay.
547
+ handler: () => { if (!chromeless) setShowHelp(prev => !prev) },
545
548
  },
546
549
  {
547
550
  id: 'command-palette',
@@ -550,8 +553,10 @@ function AppInner() {
550
553
  category: 'General' as const,
551
554
  scope: 'global' as const,
552
555
  allowInInputs: true,
553
- // Standalone focuses the top-center omnibar; embedded opens the modal.
554
- handler: () => { if (showNavRail) omnibarRef.current?.focus(); else setShowCommandPalette(true) },
556
+ // Standalone focuses the top-center omnibar; embedded opens the modal. In
557
+ // a chromeless embed the HOST owns ⌘K (its own omnibar), so do nothing —
558
+ // otherwise both the host omnibar and Radar's palette fire on one ⌘K.
559
+ handler: () => { if (showNavRail) omnibarRef.current?.focus(); else if (!chromeless) setShowCommandPalette(true) },
555
560
  },
556
561
  {
557
562
  id: 'diagnostics',
@@ -1130,7 +1135,7 @@ function AppInner() {
1130
1135
  // Fleet mode overrides visible kinds to show only CAPI resources + Node
1131
1136
  const effectiveKinds = topologyMode === 'fleet' ? FLEET_MODE_KINDS : visibleKinds
1132
1137
 
1133
- // Filter by namespace (frontend-side) and by visible kinds
1138
+ // Filter by namespace (client-side) and by visible kinds
1134
1139
  const nsSet = namespaces.length > 0 ? new Set(namespaces) : null
1135
1140
  const filteredNodes = displayedTopology.nodes.filter(node =>
1136
1141
  effectiveKinds.has(node.kind) &&
@@ -1352,7 +1357,7 @@ function AppInner() {
1352
1357
  Fills the space the pill bar left; embedded keeps the pills + modal. */}
1353
1358
  {showNavRail && (
1354
1359
  <div className="hidden sm:flex flex-1 justify-center min-w-0 px-3">
1355
- <Omnibar
1360
+ <RadarOmnibar
1356
1361
  ref={omnibarRef}
1357
1362
  onNavigateView={(view) => setMainView(view)}
1358
1363
  onNavigateKind={(kind, group) => {
@@ -1851,6 +1856,9 @@ function AppInner() {
1851
1856
  <ResourceDetailDrawer
1852
1857
  resource={drawerResource}
1853
1858
  initialTab={drawerInitialTab}
1859
+ // No Radar header in chromeless embeds (Radar Hub) — anchor the drawer
1860
+ // to the top of the content area instead of leaving a 49px gap.
1861
+ headerHeight={chromeless ? 0 : undefined}
1854
1862
  isOpen={resourceDrawer.isOpen}
1855
1863
  expanded={drawerExpanded}
1856
1864
  onClose={() => { setSelectedResource(null); setDrawerInitialTab('detail'); setDrawerExpanded(false) }}
package/src/api/client.ts CHANGED
@@ -717,6 +717,11 @@ export interface SearchHit {
717
717
  name: string
718
718
  matched?: SearchMatchedField[]
719
719
  summaryContext?: SearchSummaryContext
720
+ /** Embedder (Radar Hub) only: the cluster this hit belongs to, for
721
+ * cross-cluster fleet search. Standalone Radar (single-cluster) leaves these
722
+ * unset — the omnibar keys + displays the cluster only when present. */
723
+ cluster?: string
724
+ clusterName?: string
720
725
  }
721
726
 
722
727
  export interface SearchResult {
@@ -817,7 +822,7 @@ export function useAuthMe() {
817
822
  }
818
823
 
819
824
  // Tier ordering for Cloud-role gates. Mirrors radar OSS pkg/auth
820
- // CloudRole.AtLeast — the SPA must agree with the backend on what
825
+ // CloudRole.AtLeast — the frontend must agree with the backend on what
821
826
  // "member-or-higher" means; otherwise we'd hide a button the
822
827
  // backend would happily honor (or vice versa).
823
828
  const CLOUD_ROLE_RANK: Record<string, number> = { viewer: 1, member: 2, owner: 3 }
@@ -1036,6 +1041,7 @@ export interface UseChangesOptions {
1036
1041
  filter?: string // Filter preset name ('default', 'all', 'warnings-only', 'workloads')
1037
1042
  includeK8sEvents?: boolean
1038
1043
  includeManaged?: boolean
1044
+ includeDeleted?: boolean
1039
1045
  limit?: number
1040
1046
  enabled?: boolean
1041
1047
  }
@@ -1060,7 +1066,7 @@ function getTimeRangeDate(range: TimeRange): Date | null {
1060
1066
  }
1061
1067
 
1062
1068
  export function useChanges(options: UseChangesOptions = {}) {
1063
- const { namespaces = [], kind, timeRange = '1h', filter = 'all', includeK8sEvents = true, includeManaged = false, limit = 200, enabled = true } = options
1069
+ const { namespaces = [], kind, timeRange = '1h', filter = 'all', includeK8sEvents = true, includeManaged = false, includeDeleted = true, limit = 200, enabled = true } = options
1064
1070
 
1065
1071
  const params = new URLSearchParams()
1066
1072
  if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
@@ -1068,6 +1074,7 @@ export function useChanges(options: UseChangesOptions = {}) {
1068
1074
  if (filter) params.set('filter', filter)
1069
1075
  if (!includeK8sEvents) params.set('include_k8s_events', 'false')
1070
1076
  if (includeManaged) params.set('include_managed', 'true')
1077
+ if (!includeDeleted) params.set('include_deleted', 'false')
1071
1078
  params.set('limit', String(limit))
1072
1079
 
1073
1080
  const sinceDate = getTimeRangeDate(timeRange)
@@ -1078,7 +1085,7 @@ export function useChanges(options: UseChangesOptions = {}) {
1078
1085
  const queryString = params.toString()
1079
1086
 
1080
1087
  return useQuery<TimelineEvent[]>({
1081
- queryKey: ['changes', namespaces, kind, timeRange, filter, includeK8sEvents, includeManaged, limit],
1088
+ queryKey: ['changes', namespaces, kind, timeRange, filter, includeK8sEvents, includeManaged, includeDeleted, limit],
1082
1089
  queryFn: () => fetchJSON(`/changes${queryString ? `?${queryString}` : ''}`),
1083
1090
  staleTime: 5000, // Consider data stale after 5 seconds to ensure fresh data on navigation
1084
1091
  refetchInterval: 60000, // SSE handles real-time updates; this is a fallback
@@ -27,7 +27,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
27
27
 
28
28
  // Repo refresh is gated only by `requireHelmWrite` on the backend
29
29
  // (handleUpdateRepository deliberately skips requireCloudRole — it
30
- // mutates pod-local chart cache, not cluster state). So the SPA gate
30
+ // mutates pod-local chart cache, not cluster state). So the frontend gate
31
31
  // here must NOT include the Cloud role check, or Cloud viewers with
32
32
  // rbac.helm=true would be blocked from a refresh the backend allows.
33
33
  const canHelmWrite = useCanHelmWrite()
@@ -22,7 +22,7 @@ interface RoleGatedPanelProps {
22
22
  *
23
23
  * Bypasses for non-Cloud users (OSS, OIDC, etc.) — `canAtLeast` returns
24
24
  * true when no Cloud role is present. The backend gate has the same
25
- * shape, so the SPA stays in lockstep.
25
+ * shape, so the frontend stays in lockstep.
26
26
  */
27
27
  export function RoleGatedPanel({ min, feature, children }: RoleGatedPanelProps) {
28
28
  const { role, canAtLeast } = useCloudRole()
@@ -18,6 +18,9 @@ interface ResourceDetailDrawerProps {
18
18
  onExpand?: (resource: SelectedResource) => void
19
19
  /** Navigate to another resource within expanded WorkloadView */
20
20
  onNavigateToResource?: (resource: SelectedResource) => void
21
+ /** Top offset for the drawer (px). Defaults to Radar's 49px header height;
22
+ * pass 0 in chromeless embeds where there's no Radar header above it. */
23
+ headerHeight?: number
21
24
  }
22
25
 
23
26
  export function ResourceDetailDrawer(props: ResourceDetailDrawerProps) {
@@ -15,9 +15,11 @@ interface TimelineListProps {
15
15
  onResourceClick?: NavigateToResource
16
16
  initialFilter?: ActivityTypeFilter
17
17
  initialTimeRange?: TimeRange
18
+ showDeleted: boolean
19
+ onShowDeletedChange: (showDeleted: boolean) => void
18
20
  }
19
21
 
20
- export function TimelineList({ namespaces, onViewChange, currentView, onResourceClick, initialFilter, initialTimeRange }: TimelineListProps) {
22
+ export function TimelineList({ namespaces, onViewChange, currentView, onResourceClick, initialFilter, initialTimeRange, showDeleted, onShowDeletedChange }: TimelineListProps) {
21
23
  const hasLimitedAccess = useHasLimitedAccess()
22
24
  const [queryParams, setQueryParams] = useState<{ timeRange: TimeRange; kind?: string }>({
23
25
  timeRange: initialTimeRange ?? '1h',
@@ -32,6 +34,7 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
32
34
  kind: queryParams.kind,
33
35
  timeRange: queryParams.timeRange,
34
36
  includeK8sEvents: true,
37
+ includeDeleted: showDeleted,
35
38
  limit: 500,
36
39
  })
37
40
 
@@ -64,6 +67,8 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
64
67
  onResourceClick={onResourceClick}
65
68
  initialFilter={initialFilter}
66
69
  initialTimeRange={initialTimeRange}
70
+ showDeleted={showDeleted}
71
+ onShowDeletedChange={onShowDeletedChange}
67
72
  />
68
73
  )
69
74
  }
@@ -42,6 +42,10 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
42
42
  // Force list view on large clusters without namespace filter
43
43
  const effectiveInitialMode = requiresNamespaceFilter ? 'list' : (initialViewMode ?? 'swimlane')
44
44
  const [viewMode, setViewMode] = useState<TimelineViewMode>(effectiveInitialMode)
45
+ // Shared across list + swimlane so the toggle carries across the view switch,
46
+ // and so the swimlane fetch can exclude deletes server-side (before LIMIT)
47
+ // rather than only hiding them client-side after the 10k cap.
48
+ const [showDeleted, setShowDeleted] = useState(true)
45
49
 
46
50
  // Only fetch heavy swimlane data when actually showing swimlanes
47
51
  const showSwimlanes = viewMode === 'swimlane' && !requiresNamespaceFilter
@@ -53,6 +57,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
53
57
  timeRange: 'all',
54
58
  includeK8sEvents: true,
55
59
  includeManaged: true,
60
+ includeDeleted: showDeleted,
56
61
  limit: 10000,
57
62
  enabled: showSwimlanes,
58
63
  })
@@ -119,6 +124,8 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
119
124
  onViewModeChange={setViewMode}
120
125
  topology={stableTopology}
121
126
  namespaces={namespaces}
127
+ showDeleted={showDeleted}
128
+ onShowDeletedChange={setShowDeleted}
122
129
  />
123
130
  )
124
131
  }
@@ -131,6 +138,8 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
131
138
  onResourceClick={onResourceClick}
132
139
  initialFilter={initialFilter}
133
140
  initialTimeRange={initialTimeRange}
141
+ showDeleted={showDeleted}
142
+ onShowDeletedChange={setShowDeleted}
134
143
  />
135
144
  )
136
145
  }
@@ -4,12 +4,29 @@ import { Search, CornerDownLeft, Loader2, AlertTriangle } from 'lucide-react'
4
4
  import { clsx } from 'clsx'
5
5
  import { SearchPillInput, type SearchModifier } from '@skyhook-io/k8s-ui'
6
6
  import { getResourceIcon } from '../../utils/resource-icons'
7
- import { useSearch, useNamespaceScope, useContexts, type SearchHit, type SearchMatchedField } from '../../api/client'
8
- import { useAPIResources } from '../../api/apiResources'
9
- import { loadRecentResources, recordRecentResource } from '../../hooks/useRecentResources'
10
- import { useCommandItems, bestScore, type CommandItem, type CommandItemCallbacks } from './command-items'
7
+ import type { SearchHit, SearchMatchedField } from '../../api/client'
8
+ import { bestScore, type CommandItem } from './command-items'
11
9
  import { SearchSyntaxHelp } from './SearchSyntaxHelp'
12
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
+
13
30
  // Health → dot color (summaryContext.health is the same vocabulary as the rest
14
31
  // of Radar). Kept local + tiny to avoid pulling the full status-tone system.
15
32
  function healthDot(health?: string): string | null {
@@ -66,14 +83,50 @@ export interface OmnibarHandle {
66
83
  focus: () => void
67
84
  }
68
85
 
69
- interface OmnibarProps extends CommandItemCallbacks {
70
- /** Open a resource hit (route-based — sets the URL + opens the drawer). */
86
+ export interface OmnibarProps {
87
+ /** Open a resource hit (route-based — sets the URL + opens the drawer/page). */
71
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
72
124
  }
73
125
 
74
126
  type Row =
75
127
  | { id: string; kind: 'resource'; hit: SearchHit; recent?: boolean }
76
128
  | { id: string; kind: 'command'; command: CommandItem }
129
+ | { id: string; kind: 'viewAll'; query: string; count: number }
77
130
 
78
131
  const COMMAND_CATEGORY_ORDER = ['Views', 'Resource Kinds', 'Namespaces', 'Clusters', 'Actions']
79
132
  const PAGE = 8
@@ -83,12 +136,32 @@ function pillsToQuery(pills: SearchModifier[]): string {
83
136
  return pills.map((p) => `${p.key}:${p.value}`).join(' ')
84
137
  }
85
138
 
86
- // The standalone omnibar: a persistent top-center search box that IS the ⌘K
87
- // surface. Typing runs the live, GLOBAL resource search (/api/search) alongside
88
- // the command-palette items; modifiers (ns:, kind:, …) become removable pills.
89
- // Resources lead, commands follow. ⌘K focuses it.
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.
90
146
  export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
91
- { onOpenResource, ...callbacks },
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
+ },
92
165
  ref,
93
166
  ) {
94
167
  const [text, setText] = useState('')
@@ -102,67 +175,59 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
102
175
  // The dropdown is portaled to <body> (so the header's stacking context can't
103
176
  // trap the dim overlay). `centerX` aligns the panel under the input; `top` is
104
177
  // the HEADER's bottom (not the input's) so the dim starts cleanly below the
105
- // whole top bar — the input is shorter than the bar, so anchoring to it would
106
- // slice the dim through the taller right-side controls.
107
- const [anchor, setAnchor] = useState<{ centerX: number; top: number } | null>(null)
178
+ // whole top bar.
179
+ const [anchor, setAnchor] = useState<{ centerX: number; top: number; width: number } | null>(null)
108
180
 
109
181
  useImperativeHandle(ref, () => ({ focus: () => { inputRef.current?.focus(); inputRef.current?.select() } }), [])
110
182
 
111
- const { data: nsScope } = useNamespaceScope()
112
- const { data: apiResources } = useAPIResources()
113
- const { data: contexts } = useContexts()
114
- // Recents are partitioned by the current cluster so a context switch never
115
- // surfaces (or opens) the previous cluster's resources.
116
- const contextKey = useMemo(() => contexts?.find((c) => c.isCurrent)?.name ?? '', [contexts])
117
- // ns + kind are the bounded, knowable modifier value sets worth autocompleting.
118
- const modifierOptions = useMemo(() => ({
119
- ns: nsScope?.accessibleNamespaces ?? [],
120
- kind: apiResources ? [...new Set(apiResources.filter((r) => r.verbs?.includes('list')).map((r) => r.kind))].sort() : [],
121
- }), [nsScope, apiResources])
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])
122
189
 
123
190
  // Reflect the current view scope as an editable `ns:` pill on open, so a
124
191
  // deliberately broad ⌘K search shows (and lets you remove) the namespace it's
125
- // narrowed to instead of silently scoping. Seeded once per open, only from a
126
- // truly empty launcher state.
127
- const actives = nsScope?.actives
192
+ // narrowed to. Seeded once per open, only from a truly empty launcher state.
128
193
  const seededRef = useRef(false)
129
194
  useEffect(() => {
130
195
  if (!open) { seededRef.current = false; return }
131
- if (seededRef.current || actives === undefined) return
196
+ if (seededRef.current || seedNamespaces === undefined) return
132
197
  seededRef.current = true
133
- if (pills.length === 0 && text === '' && actives.length > 0) {
134
- setPills(actives.map((ns) => ({ key: 'ns', value: ns })))
198
+ if (pills.length === 0 && text === '' && seedNamespaces.length > 0) {
199
+ setPills(seedNamespaces.map((ns) => ({ key: 'ns', value: ns })))
135
200
  }
136
- }, [open, actives, pills.length, text])
201
+ }, [open, seedNamespaces, pills.length, text])
137
202
 
138
203
  const freeText = text.trim()
139
204
  const queryString = useMemo(() => [pillsToQuery(pills), freeText].filter(Boolean).join(' '), [pills, freeText])
140
205
  const searchActive = queryString.length >= 2
141
- // Small debounce: /api/search is a local in-memory index, so this exists only
142
- // to coalesce fast keystrokes (less list reshuffle), not to cut network cost —
143
- // kept under the ~100-150ms "feels instant" threshold. keepPreviousData +
144
- // AbortSignal (see useSearch) handle the smoothness; commands aren't debounced.
206
+ // Small debounce: coalesce fast keystrokes (less list reshuffle). The host's
207
+ // search hook handles smoothness (keepPreviousData + AbortSignal).
145
208
  const debounced = useDebounced(queryString, 120)
146
- // globalNs: ⌘K searches the user's full RBAC ceiling; scope comes only from
147
- // `ns:` pills, never the silent view filter.
148
- const { data: searchData, isFetching, isPlaceholderData, isError } = useSearch(debounced, { enabled: open, globalNs: true })
149
209
 
150
- const commandItems = useCommandItems(callbacks)
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])
151
213
 
152
214
  // Commands score against the FREE text only — modifiers live in pills, so the
153
- // launcher never sees "ns:" polluting a "go to topology" match. Empty + no
154
- // pills Views + Actions (launcher default); with pills but no text the user
155
- // is browsing a scope, so suppress the command default.
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.
156
220
  const scoredCommands = useMemo(() => {
157
221
  if (!freeText) {
158
- return pills.length ? [] : commandItems.filter((i) => i.category === 'Views' || i.category === 'Actions').map((item) => ({ item, score: 1 }))
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 }))
159
225
  }
160
226
  return commandItems.map((item) => ({ item, score: bestScore(item, freeText) })).filter((x) => x.score > 0).sort((a, b) => b.score - a.score)
161
- }, [commandItems, freeText, pills.length])
227
+ }, [commandItems, freeText, pills.length, launcherShowsViews])
162
228
 
163
229
  // Kinds whose NAME strongly matches (exact 150 / prefix 100) lead ABOVE the
164
- // resource instances: "⌘K → deployment → Deployments list" is a navigation
165
- // flow the instance hits otherwise bury.
230
+ // resource instances.
166
231
  const leadingKinds = useMemo<CommandItem[]>(
167
232
  () => (freeText.length < 2 ? [] : scoredCommands.filter((x) => x.item.category === 'Resource Kinds' && x.score >= STRONG_KIND).slice(0, 5).map((x) => x.item)),
168
233
  [scoredCommands, freeText],
@@ -171,56 +236,79 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
171
236
 
172
237
  const resourceRows = useMemo<Row[]>(() => {
173
238
  const hits = searchData?.hits ?? []
174
- return hits.map((hit) => ({ id: `res:${hit.kind}:${hit.group || ''}:${hit.namespace || ''}:${hit.name}`, kind: 'resource' as const, hit }))
239
+ return hits.map((hit) => ({ id: `res:${hit.cluster || ''}:${hit.kind}:${hit.group || ''}:${hit.namespace || ''}:${hit.name}`, kind: 'resource' as const, hit }))
175
240
  }, [searchData])
176
241
 
177
- // Launcher recents: only in the truly-empty state (no text, no pills). Read
178
- // fresh from localStorage each open.
242
+ // Launcher recents: only in the truly-empty state (no text, no pills).
179
243
  const recentRows = useMemo<Row[]>(() => {
180
- if (!open || freeText || pills.length > 0) return []
181
- return loadRecentResources(contextKey).map((r) => ({
182
- id: `recent:${r.kind}:${r.group || ''}:${r.namespace || ''}:${r.name}`,
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}`,
183
247
  kind: 'resource' as const,
184
248
  recent: true,
185
- hit: { score: 0, kind: r.kind, group: r.group, namespace: r.namespace, name: r.name } as SearchHit,
249
+ hit: { score: 0, kind: r.kind, group: r.group, namespace: r.namespace, name: r.name, cluster: r.cluster, clusterName: r.clusterName } as SearchHit,
186
250
  }))
187
- }, [open, freeText, pills.length, contextKey])
251
+ }, [open, freeText, pills.length, loadRecents])
188
252
 
189
253
  // Remaining matched commands (leading kinds removed so they don't repeat),
190
- // grouped by their real category in a fixed order.
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.
191
257
  const commandGroups = useMemo(() => {
192
- const rest = scoredCommands.filter((x) => !leadingIds.has(x.item.id)).slice(0, 8).map((x) => x.item)
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)
193
261
  const byCat = new Map<string, CommandItem[]>()
194
262
  for (const c of rest) { const l = byCat.get(c.category) ?? []; l.push(c); byCat.set(c.category, l) }
195
- return COMMAND_CATEGORY_ORDER.filter((cat) => byCat.has(cat)).map((cat) => ({ category: cat, items: byCat.get(cat)! }))
196
- }, [scoredCommands, leadingIds])
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])
197
270
 
198
271
  const toCmdRow = (c: CommandItem): Row => ({ id: `cmd:${c.id}`, kind: 'command', command: c })
199
272
 
200
- // Free-text tokens for highlighting command labels (commands are scored
201
- // client-side, so there's no server `matched`).
202
273
  const queryTokens = useMemo(() => freeText.split(/\s+/).filter(Boolean), [freeText])
203
274
 
204
- // Ordered, id-stable list (render order == keyboard model): recents (launcher
205
- // only), then leading kinds, then resources (when searchActive), then commands.
275
+ // Ordered, id-stable list (render order == keyboard model).
206
276
  const rows = useMemo<Row[]>(() => {
207
277
  const cmds: Row[] = commandGroups.flatMap((g) => g.items.map(toCmdRow))
208
278
  if (!freeText && pills.length === 0) return [...recentRows, ...cmds]
209
- return [...leadingKinds.map(toCmdRow), ...(searchActive ? resourceRows : []), ...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
210
286
  // eslint-disable-next-line react-hooks/exhaustive-deps
211
- }, [recentRows, leadingKinds, resourceRows, commandGroups, freeText, pills.length, searchActive])
287
+ }, [recentRows, leadingKinds, resourceRows, commandGroups, freeText, pills.length, searchActive, onViewAllResults, queryString, searchData])
288
+ const viewAllRow = rows.find((r): r is Extract<Row, { kind: 'viewAll' }> => r.kind === 'viewAll')
212
289
 
213
290
  // Selection tracked by stable id (not array index) so Enter can never fire a
214
291
  // stale row when the set shifts. Auto-follows the TOP result until the user
215
292
  // arrow-keys; a new query re-enables auto-follow.
293
+ // When the host wires a search page (onViewAllResults), Enter on an
294
+ // un-touched query goes THERE with the query rather than firing the top hit —
295
+ // so we never pre-select a row (the user opts into a specific result by
296
+ // arrowing/hovering). Without a search page (OSS), keep auto-follow-top so
297
+ // Enter still opens the best match.
298
+ const submitToSearch = !!onViewAllResults
216
299
  const [selectedId, setSelectedId] = useState<string | null>(null)
217
300
  const userMovedRef = useRef(false)
218
301
  useEffect(() => { userMovedRef.current = false }, [queryString])
219
302
  const rowsKey = rows.map((r) => r.id).join('|')
220
303
  useEffect(() => {
304
+ // Only suppress the pre-selection while actively SEARCHING (so Enter goes to
305
+ // the search page, not a maybe-wrong top hit). In the empty launcher there's
306
+ // no search to defer to, so auto-select the first row — otherwise Enter is a
307
+ // no-op while the footer still reads "open".
308
+ const dflt = submitToSearch && searchActive ? null : (rows[0]?.id ?? null)
221
309
  setSelectedId((cur) => {
222
- if (!userMovedRef.current) return rows[0]?.id ?? null
223
- return cur && rows.some((r) => r.id === cur) ? cur : rows[0]?.id ?? null
310
+ if (!userMovedRef.current) return dflt
311
+ return cur && rows.some((r) => r.id === cur) ? cur : dflt
224
312
  })
225
313
  // eslint-disable-next-line react-hooks/exhaustive-deps
226
314
  }, [rowsKey])
@@ -230,8 +318,7 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
230
318
  setSelectedId(rows[Math.min(Math.max(selectedIndex + delta, 0), rows.length - 1)]?.id ?? null)
231
319
  }
232
320
  const selectRow = (id: string) => { userMovedRef.current = true; setSelectedId(id) }
233
- // Page by a full screenful of visible rows (minus one for context overlap),
234
- // measured from the scroll container — a fixed count feels short on tall lists.
321
+ // Page by a full screenful of visible rows (minus one for context overlap).
235
322
  const pageStep = () => {
236
323
  const list = listRef.current
237
324
  const rowH = (list?.querySelector('button') as HTMLElement | null)?.offsetHeight
@@ -242,51 +329,53 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
242
329
  const execute = useCallback((row: Row) => {
243
330
  if (row.kind === 'command') {
244
331
  row.command.action()
332
+ } else if (row.kind === 'viewAll') {
333
+ onViewAllResults?.(row.query)
245
334
  } else {
246
335
  const h = row.hit
247
- recordRecentResource({ kind: h.kind, group: h.group, namespace: h.namespace, name: h.name }, contextKey)
336
+ recordRecent?.({ kind: h.kind, group: h.group, namespace: h.namespace, name: h.name, cluster: h.cluster, clusterName: h.clusterName })
248
337
  onOpenResource(h)
249
338
  }
250
339
  setOpen(false)
251
340
  setText('')
252
341
  setPills([])
253
342
  inputRef.current?.blur()
254
- }, [onOpenResource, contextKey])
343
+ }, [onOpenResource, recordRecent, onViewAllResults])
255
344
 
256
345
  // The resources shown don't (yet) belong to the current query: the debounce
257
- // hasn't fired, the data is React Query placeholder from a prior query, or
258
- // results for this query haven't landed. Swallow Enter so it can't open a
259
- // stale hit or a command standing in for an imminent resource.
346
+ // hasn't fired, the data is React Query placeholder, or results haven't landed.
260
347
  const resourcesStale = searchActive && (debounced !== queryString || isPlaceholderData || (resourceRows.length === 0 && isFetching))
261
348
 
262
- // Forwarded from SearchPillInput for keys it doesn't consume (it owns Space →
263
- // pill, Backspace → pop pill, and suggestion nav).
264
349
  const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
265
350
  if (e.key === 'Escape') { e.preventDefault(); setOpen(false); inputRef.current?.blur(); return }
266
351
  if (e.key === 'ArrowDown') { e.preventDefault(); moveSelection(1) }
267
352
  else if (e.key === 'ArrowUp') { e.preventDefault(); moveSelection(-1) }
268
353
  else if (e.key === 'PageDown') { e.preventDefault(); moveSelection(pageStep()) }
269
354
  else if (e.key === 'PageUp') { e.preventDefault(); moveSelection(-pageStep()) }
270
- // Home/End deliberately left native so they move the text caret, not the list.
271
355
  else if (e.key === 'Enter') {
272
356
  e.preventDefault()
273
357
  const row = rows[selectedIndex]
274
- if (!row) return
275
- // Block Enter only for a stale RESOURCE row (could open a hidden/stale
276
- // hit); commands and ready resources fire immediately.
277
- if (row.kind === 'resource' && resourcesStale) return
278
- execute(row)
358
+ if (row) {
359
+ if (row.kind === 'resource' && resourcesStale) return
360
+ execute(row)
361
+ return
362
+ }
363
+ // No row chosen: submit the query to the full search page (the default
364
+ // for a host that wired one). viewAllRow carries the count when results
365
+ // are in; fall back to the raw query while they're still loading.
366
+ if (submitToSearch && searchActive) {
367
+ if (viewAllRow) execute(viewAllRow)
368
+ else { onViewAllResults?.(queryString); setOpen(false); setText(''); setPills([]); inputRef.current?.blur() }
369
+ }
279
370
  }
280
371
  // eslint-disable-next-line react-hooks/exhaustive-deps
281
- }, [rows, selectedIndex, execute, resourcesStale])
372
+ }, [rows, selectedIndex, execute, resourcesStale, submitToSearch, searchActive, viewAllRow, onViewAllResults, queryString])
282
373
 
283
- // Keep the selected row in view.
284
374
  useEffect(() => {
285
375
  listRef.current?.querySelector('[data-selected="true"]')?.scrollIntoView({ block: 'nearest' })
286
376
  }, [selectedId])
287
377
 
288
- // Close on outside click — the panel is portaled out of the container, so it
289
- // must be excluded explicitly or clicking a row would count as "outside".
378
+ // Close on outside click — the panel is portaled out of the container.
290
379
  useEffect(() => {
291
380
  if (!open) return
292
381
  const onDown = (e: MouseEvent) => {
@@ -297,8 +386,7 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
297
386
  return () => document.removeEventListener('mousedown', onDown)
298
387
  }, [open])
299
388
 
300
- // Track the input's position so the portaled panel stays anchored under it
301
- // through scroll / resize / layout shifts.
389
+ // Track the input's position so the portaled panel stays anchored under it.
302
390
  useEffect(() => {
303
391
  if (!open) { setAnchor(null); return }
304
392
  const update = () => {
@@ -306,7 +394,7 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
306
394
  if (!el) return
307
395
  const r = el.getBoundingClientRect()
308
396
  const header = el.closest('header')
309
- setAnchor({ centerX: r.left + r.width / 2, top: header ? header.getBoundingClientRect().bottom : r.bottom })
397
+ setAnchor({ centerX: r.left + r.width / 2, top: header ? header.getBoundingClientRect().bottom : r.bottom, width: r.width })
310
398
  }
311
399
  update()
312
400
  window.addEventListener('resize', update)
@@ -322,25 +410,37 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
322
410
 
323
411
  const clearNsPills = () => { setPills((prev) => prev.filter((p) => p.key !== 'ns')); inputRef.current?.focus() }
324
412
 
413
+ const hero = size === 'hero'
414
+
325
415
  return (
326
- <div ref={containerRef} className="relative w-full max-w-xl">
416
+ <div
417
+ ref={containerRef}
418
+ className={clsx('relative w-full', hero ? 'max-w-3xl' : 'max-w-xl', open && hero && 'z-[16]')}
419
+ // Open on click even when the field is already focused — onFocus alone
420
+ // never fires again, so an autofocused hero (Home) wouldn't reveal the
421
+ // launcher on a click.
422
+ onMouseDown={() => setOpen(true)}
423
+ >
327
424
  <SearchPillInput
328
- className="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"
425
+ className={hero
426
+ ? '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)]'
427
+ : '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'}
428
+ inputClassName={hero ? 'text-lg py-4' : undefined}
329
429
  text={text}
330
430
  pills={pills}
331
431
  onChange={({ text: t, pills: p }) => { setText(t); setPills(p); setOpen(true) }}
332
432
  onKeyDown={handleKeyDown}
333
- onFocus={() => setOpen(true)}
433
+ onFocus={() => { if (skipFocusOpen.current) { skipFocusOpen.current = false; return } setOpen(true) }}
334
434
  onSuggestingChange={setSuggesting}
335
435
  modifierOptions={modifierOptions}
336
- placeholder="Search resources & commands…"
436
+ placeholder={placeholder}
337
437
  aria-label="Search resources and commands"
338
438
  inputRef={inputRef}
339
- leftSlot={<Search className="w-3.5 h-3.5 shrink-0 text-theme-text-tertiary" />}
439
+ 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'} />}
340
440
  rightSlot={
341
441
  <div className="flex items-center gap-1.5 shrink-0">
342
442
  <SearchSyntaxHelp />
343
- {!text && pills.length === 0 && (
443
+ {!hero && !text && pills.length === 0 && (
344
444
  <kbd className="text-[10px] text-theme-text-tertiary bg-theme-surface px-1 py-0.5 rounded border border-theme-border-light">
345
445
  {mac ? '⌘' : 'Ctrl+'}K
346
446
  </kbd>
@@ -351,24 +451,26 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
351
451
 
352
452
  {open && anchor && (dropdownOpen || suggesting) && createPortal(
353
453
  <>
354
- {/* Dim + blur the busy dashboard behind so results read as a focused
355
- search surface (Spotlight/Linear pattern), not a weak float. Starts
356
- at the header's bottom edge so the search box + top bar stay crisp.
357
- Tied to `open`, NOT the results panel, so completing a modifier (the
358
- panel briefly yields to the autocomplete) doesn't strobe the dim. */}
454
+ {/* Scrim separates the dropdown from the page, consistently in both
455
+ modes. At z-[15] it sits BELOW the rail/top bar (z-20/30), so the
456
+ nav chrome stays lit while the content behind the panel dims+blurs:
457
+ a "spotlight on search", not a full-screen modal dim (which fits a
458
+ centered command palette, not an anchored omnibar). The hero covers
459
+ from the top (its box is in the content, lifted to z-[16]); the
460
+ top-bar launcher covers from below the field (its box is already in
461
+ the z-20 chrome). Click closes. */}
359
462
  <div
360
- className="fixed left-0 right-0 bottom-0 z-[120] bg-black/25 dark:bg-black/55 backdrop-blur-[2px]"
361
- style={{ top: anchor.top }}
463
+ className="fixed left-0 right-0 bottom-0 z-[15] bg-black/15 dark:bg-black/50 backdrop-blur-[3px]"
464
+ style={{ top: hero ? 0 : anchor.top }}
362
465
  onClick={() => { setOpen(false); inputRef.current?.blur() }}
363
466
  />
364
467
  {dropdownOpen && (
365
468
  <div
366
469
  ref={panelRef}
367
- style={{ position: 'fixed', top: anchor.top + 8, left: anchor.centerX, transform: 'translateX(-50%)', width: 640, maxWidth: 'calc(100vw - 2rem)' }}
368
- className="z-[121] dialog shadow-theme-lg overflow-hidden"
470
+ style={{ position: 'fixed', top: anchor.top + 8, left: anchor.centerX, transform: 'translateX(-50%)', width: hero ? Math.round(anchor.width) : 640, maxWidth: 'calc(100vw - 2rem)' }}
471
+ className="z-[121] dialog shadow-theme-lg ring-1 ring-black/5 dark:ring-white/10 overflow-hidden"
369
472
  >
370
473
  <div ref={listRef} className="max-h-[60vh] overflow-y-auto py-1">
371
- {/* Recently viewed — launcher state only. */}
372
474
  {recentRows.length > 0 && (
373
475
  <div>
374
476
  <div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary">Recently viewed</div>
@@ -378,8 +480,6 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
378
480
  </div>
379
481
  )}
380
482
 
381
- {/* Leading kinds — strong kind-name matches lead so ⌘K navigation
382
- to a kind isn't buried under instance hits. */}
383
483
  {leadingKinds.length > 0 && (
384
484
  <div>
385
485
  <div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary">Resource Kinds</div>
@@ -390,7 +490,6 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
390
490
  </div>
391
491
  )}
392
492
 
393
- {/* Resources section */}
394
493
  {searchActive && (
395
494
  <>
396
495
  <div className="flex items-center justify-between px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary">
@@ -413,16 +512,12 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
413
512
  </div>
414
513
  ) : (
415
514
  resourceRows.map((row) => row.kind === 'resource' && (
416
- // Mirror the Enter guard: ignore clicks on stale rows (prior
417
- // query's results during debounce/placeholder) so a click can't
418
- // open/record the wrong resource. Dim them so it reads as pending.
419
515
  <ResourceRow key={row.id} hit={row.hit} stale={resourcesStale} selected={row.id === selectedId} onSelect={() => selectRow(row.id)} onActivate={() => { if (!resourcesStale) execute(row) }} />
420
516
  ))
421
517
  )}
422
518
  </>
423
519
  )}
424
520
 
425
- {/* Command groups, each under its real category header. */}
426
521
  {commandGroups.map((group) => (
427
522
  <div key={group.category}>
428
523
  <div className="px-3 py-1 mt-1 text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary">{group.category}</div>
@@ -432,9 +527,25 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
432
527
  })}
433
528
  </div>
434
529
  ))}
530
+
531
+ {viewAllRow && (
532
+ <button
533
+ type="button"
534
+ data-selected={viewAllRow.id === selectedId}
535
+ onMouseEnter={() => selectRow(viewAllRow.id)}
536
+ onMouseDown={(e) => { e.preventDefault(); execute(viewAllRow) }}
537
+ 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')}
538
+ >
539
+ <Search className="w-4 h-4 shrink-0 text-theme-text-tertiary" />
540
+ <span className="text-sm text-[var(--color-brand)]">See all {viewAllRow.count} result{viewAllRow.count === 1 ? '' : 's'}</span>
541
+ <CornerDownLeft className="w-3 h-3 ml-auto shrink-0 text-theme-text-tertiary" />
542
+ </button>
543
+ )}
435
544
  </div>
436
545
  <div className="flex items-center gap-3 px-3 py-1.5 border-t border-theme-border text-[11px] text-theme-text-tertiary">
437
- <span className="flex items-center gap-1"><CornerDownLeft className="w-3 h-3" /> open</span>
546
+ <span className="flex items-center gap-1">
547
+ <CornerDownLeft className="w-3 h-3" /> {submitToSearch && searchActive && selectedIndex < 0 ? 'search all' : 'open'}
548
+ </span>
438
549
  <span>↑↓ navigate</span>
439
550
  <span>⇞⇟ page</span>
440
551
  <span>esc close</span>
@@ -452,8 +563,6 @@ function ResourceRow({ hit, selected, stale, onSelect, onActivate }: { hit: Sear
452
563
  const Icon = getResourceIcon(hit.kind)
453
564
  const dot = healthDot(hit.summaryContext?.health)
454
565
  const issues = hit.summaryContext?.issueCount ?? 0
455
- // Lead is a name match; flag content-only matches so a name search isn't
456
- // silently padded with body hits.
457
566
  const contentOnly = !!hit.matched?.length && hit.matched.every((m) => m.site.startsWith('content:'))
458
567
  return (
459
568
  <button
@@ -468,6 +577,7 @@ function ResourceRow({ hit, selected, stale, onSelect, onActivate }: { hit: Sear
468
577
  <span className="shrink-0 max-w-[45%] truncate text-xs text-theme-text-tertiary">
469
578
  {highlight(hit.kind, tokensForSite(hit.matched, 'kind'))}
470
579
  {hit.namespace ? <> · {highlight(hit.namespace, tokensForSite(hit.matched, 'namespace'))}</> : ''}
580
+ {hit.clusterName ? <> · <span className="text-theme-text-secondary">{hit.clusterName}</span></> : ''}
471
581
  </span>
472
582
  {contentOnly && <span className="shrink-0 text-[10px] text-theme-text-tertiary italic">in spec</span>}
473
583
  {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>}
@@ -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
+ })
package/src/index.ts CHANGED
@@ -31,3 +31,18 @@ export type { ClusterSwitcherProps, ClusterSwitcherItem } from '@skyhook-io/k8s-
31
31
  // cluster prefix (e.g. /c/:id).
32
32
  export { resourcePath, buildWorkloadPath } from './utils/navigation';
33
33
  export type { SelectedResource } from '@skyhook-io/k8s-ui/types/core';
34
+
35
+ // Injectable omnibar — the standalone search/command surface, decoupled from
36
+ // Radar's own data hooks so embedders (Radar Hub) can drive it with fleet
37
+ // search + their own command items while sharing the exact UX (pills, modifier
38
+ // autocomplete, kind-first ranking, match highlighting, keyboard nav, recents).
39
+ export { Omnibar } from './components/ui/Omnibar';
40
+ export type {
41
+ OmnibarProps,
42
+ OmnibarHandle,
43
+ OmnibarRecent,
44
+ OmnibarSearchResult,
45
+ } from './components/ui/Omnibar';
46
+ export { bestScore } from './components/ui/command-items';
47
+ export type { CommandItem } from './components/ui/command-items';
48
+ export type { SearchHit, SearchMatchedField } from './api/client';