@skyhook-io/radar-app 1.6.0 → 1.6.2

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.
@@ -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
+ })