@skyhook-io/radar-app 1.8.6 → 1.8.7

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 (75) hide show
  1. package/README.md +7 -1
  2. package/package.json +11 -9
  3. package/src/App.tsx +221 -181
  4. package/src/RadarApp.tsx +91 -20
  5. package/src/api/client.delta.test.ts +89 -0
  6. package/src/api/client.deltaSync.test.ts +216 -0
  7. package/src/api/client.ts +262 -34
  8. package/src/api/diagnose.ts +288 -0
  9. package/src/api/timelineSource.test.ts +217 -0
  10. package/src/api/timelineSource.ts +580 -0
  11. package/src/components/ConnectionErrorView.tsx +174 -70
  12. package/src/components/ContextSwitcher.tsx +13 -5
  13. package/src/components/applications/ApplicationsView.tsx +327 -26
  14. package/src/components/audit/AuditSettingsDialog.tsx +2 -2
  15. package/src/components/curl/ServiceCurlButton.tsx +2 -2
  16. package/src/components/diagnose/AISettings.tsx +121 -0
  17. package/src/components/diagnose/DiagnoseContext.tsx +491 -0
  18. package/src/components/diagnose/DiagnoseSurface.tsx +385 -0
  19. package/src/components/diagnose/Home.tsx +163 -0
  20. package/src/components/diagnose/InvestigationView.tsx +604 -0
  21. package/src/components/diagnose/LocalDiagnoseAction.tsx +150 -0
  22. package/src/components/diagnose/launch.ts +65 -0
  23. package/src/components/diagnose/parts.tsx +1689 -0
  24. package/src/components/dock/BottomDock.tsx +2 -3
  25. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  26. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  27. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  28. package/src/components/execution/batch-run-actions.test.ts +48 -0
  29. package/src/components/execution/batch-run-actions.ts +24 -0
  30. package/src/components/execution/batch-timeline.test.ts +57 -0
  31. package/src/components/execution/batch-timeline.ts +46 -0
  32. package/src/components/execution/execution-definition.test.ts +208 -0
  33. package/src/components/execution/execution-definition.ts +245 -0
  34. package/src/components/helm/ChartBrowser.tsx +2 -3
  35. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  36. package/src/components/helm/HelmReleaseDrawer.tsx +376 -43
  37. package/src/components/helm/HelmView.tsx +2 -3
  38. package/src/components/helm/InstallWizard.tsx +3 -5
  39. package/src/components/helm/TrackChartSourceDialog.tsx +48 -4
  40. package/src/components/helm/ValuesDiffPreview.tsx +15 -4
  41. package/src/components/home/HomeView.tsx +17 -15
  42. package/src/components/home/MCPSetupDialog.tsx +1 -1
  43. package/src/components/home/mcpToolCatalog.ts +2 -2
  44. package/src/components/issues/IssuesPane.tsx +9 -1
  45. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  46. package/src/components/portforward/PortForwardButton.tsx +2 -2
  47. package/src/components/portforward/PortForwardManager.tsx +19 -14
  48. package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
  49. package/src/components/resource/RightsizingStrip.tsx +0 -3
  50. package/src/components/resources/ImageFilesystemModal.tsx +2 -2
  51. package/src/components/resources/PodFilesystemModal.tsx +2 -3
  52. package/src/components/resources/ResourceDetailDrawer.tsx +2 -0
  53. package/src/components/resources/ResourcesView.tsx +13 -4
  54. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  55. package/src/components/resources/renderers/index.ts +1 -0
  56. package/src/components/settings/SettingsDialog.tsx +80 -23
  57. package/src/components/shared/LargeClusterNamespacePicker.tsx +2 -2
  58. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  59. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  60. package/src/components/timeline/TimelineList.tsx +51 -10
  61. package/src/components/timeline/TimelineView.tsx +701 -15
  62. package/src/components/timeline/TimelineView.urlparams.test.ts +294 -0
  63. package/src/components/traffic/TrafficFilterSidebar.tsx +2 -2
  64. package/src/components/traffic/TrafficView.tsx +8 -4
  65. package/src/components/ui/CommandPalette.tsx +2 -2
  66. package/src/components/workload/WorkloadView.tsx +319 -35
  67. package/src/context/ConnectionContext.tsx +109 -11
  68. package/src/context/DiagnoseCustomization.tsx +42 -0
  69. package/src/context/TimelineSource.tsx +50 -0
  70. package/src/hooks/useClusterLoadState.ts +73 -0
  71. package/src/hooks/useEventSource.ts +6 -0
  72. package/src/index.css +157 -0
  73. package/src/index.ts +12 -0
  74. package/src/types/clusterLoadState.ts +33 -0
  75. package/src/utils/navigation.ts +10 -0
@@ -0,0 +1,294 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { ActivityFilterKey, TimelineGrouping, TimelineSort } from '@skyhook-io/k8s-ui'
3
+ import {
4
+ parseTimeMode,
5
+ writeTimelineParams,
6
+ onlyHighFreqDiffer,
7
+ timeModeEqual,
8
+ type TimelineMode,
9
+ type PersistedTimelineState,
10
+ type TimelineViewMode,
11
+ } from './TimelineView'
12
+
13
+ // These mirror the module-private defaults writeTimelineParams omits at (and
14
+ // parseTimeMode falls back to). Kept in sync deliberately: the round-trip tests
15
+ // below fail loudly if a module default drifts from what is asserted here.
16
+ const DEFAULT_LIVE_WIDTH_MS = 60 * 60 * 1000
17
+ const DAY_MS = 24 * 60 * 60 * 1000
18
+ const DEFAULT_VIEW: TimelineViewMode = 'swimlane'
19
+ const DEFAULT_GROUPING: TimelineGrouping = 'app'
20
+ const DEFAULT_SORT: TimelineSort = 'importance'
21
+ const ACTIVITY_KEYS: readonly ActivityFilterKey[] = ['changes', 'k8s_events', 'warnings', 'unhealthy']
22
+ const GROUPINGS: readonly TimelineGrouping[] = ['app', 'owner', 'flat']
23
+ const SORTS: readonly TimelineSort[] = ['importance', 'recent', 'name']
24
+
25
+ const live = (widthMs: number, all?: boolean): TimelineMode => (all ? { kind: 'live', widthMs, all } : { kind: 'live', widthMs })
26
+ const frozen = (fromMs: number, toMs: number): TimelineMode => ({ kind: 'frozen', fromMs, toMs })
27
+ const sp = (obj: Record<string, string>) => new URLSearchParams(obj)
28
+
29
+ // The inverse of writeTimelineParams, reconstructed from the component's own
30
+ // URL->state read (TimelineView's sync effect). Uses the exported parseTimeMode
31
+ // for the time mode; the remaining fields parse inline the same way the
32
+ // component does, so parse(write(state)) exercises the real deep-link contract.
33
+ const parseEnum = <T extends string>(value: string | null, allowed: readonly T[], fallback: T): T =>
34
+ value != null && (allowed as readonly string[]).includes(value) ? (value as T) : fallback
35
+
36
+ function parseState(
37
+ params: URLSearchParams,
38
+ opts: { isRetained: boolean; requiresNamespaceFilter: boolean; maxRangeDays?: number; hasPins: boolean },
39
+ ): PersistedTimelineState {
40
+ const view = params.get('view')
41
+ const viewMode: TimelineViewMode = opts.requiresNamespaceFilter
42
+ ? 'list'
43
+ : view === 'list' || view === 'swimlane'
44
+ ? view
45
+ : DEFAULT_VIEW
46
+ const activityRaw = params.get('activity')
47
+ const activityFilter =
48
+ activityRaw == null
49
+ ? []
50
+ : activityRaw
51
+ .split(',')
52
+ .map((s) => s.trim())
53
+ .filter((s): s is ActivityFilterKey => (ACTIVITY_KEYS as readonly string[]).includes(s))
54
+ const kindsRaw = params.get('kinds')
55
+ const kindFilter = kindsRaw ? kindsRaw.split(',').map((s) => s.trim()).filter(Boolean) : []
56
+ return {
57
+ viewMode,
58
+ mode: parseTimeMode(params, opts.isRetained, opts.maxRangeDays),
59
+ showDeleted: params.get('deleted') !== '0',
60
+ pinnedOnly: params.get('pinnedOnly') === '1' && opts.hasPins,
61
+ search: params.get('q') ?? '',
62
+ activityFilter,
63
+ kindFilter,
64
+ grouping: parseEnum(params.get('grouping'), GROUPINGS, DEFAULT_GROUPING),
65
+ sort: parseEnum(params.get('sort'), SORTS, DEFAULT_SORT),
66
+ selectedEventId: params.get('event'),
67
+ }
68
+ }
69
+
70
+ const defaultState: PersistedTimelineState = {
71
+ viewMode: DEFAULT_VIEW,
72
+ mode: live(DEFAULT_LIVE_WIDTH_MS),
73
+ showDeleted: true,
74
+ pinnedOnly: false,
75
+ search: '',
76
+ activityFilter: [],
77
+ kindFilter: [],
78
+ grouping: DEFAULT_GROUPING,
79
+ sort: DEFAULT_SORT,
80
+ selectedEventId: null,
81
+ }
82
+
83
+ const sortedEntries = (params: URLSearchParams) => [...params.entries()].sort(([a], [b]) => a.localeCompare(b))
84
+
85
+ describe('parseTimeMode', () => {
86
+ it('round-trips the live-window presets encoded in ?window', () => {
87
+ expect(parseTimeMode(sp({ window: 'all' }), true)).toEqual(live(DEFAULT_LIVE_WIDTH_MS, true))
88
+ expect(parseTimeMode(sp({ window: '1800000' }), true)).toEqual(live(1_800_000))
89
+ })
90
+
91
+ it('parses an explicit from/to into a frozen window', () => {
92
+ const from = 1_700_000_000_000
93
+ const to = from + 2 * DAY_MS
94
+ expect(parseTimeMode(sp({ from: String(from), to: String(to) }), true)).toEqual(frozen(from, to))
95
+ })
96
+
97
+ it('rejects from >= to and falls back to the default live window', () => {
98
+ const t = 1_700_000_000_000
99
+ expect(parseTimeMode(sp({ from: String(t), to: String(t) }), true)).toEqual(live(DEFAULT_LIVE_WIDTH_MS))
100
+ expect(parseTimeMode(sp({ from: String(t + 1000), to: String(t) }), true)).toEqual(live(DEFAULT_LIVE_WIDTH_MS))
101
+ })
102
+
103
+ it('caps an out-of-retention from to maxRangeDays before to', () => {
104
+ const to = 10 * DAY_MS
105
+ expect(parseTimeMode(sp({ from: '1', to: String(to) }), true, 7)).toEqual(frozen(to - 7 * DAY_MS, to))
106
+ })
107
+
108
+ it('leaves from uncapped when already within maxRangeDays', () => {
109
+ const to = 10 * DAY_MS
110
+ const from = to - 3 * DAY_MS
111
+ expect(parseTimeMode(sp({ from: String(from), to: String(to) }), true, 7)).toEqual(frozen(from, to))
112
+ })
113
+
114
+ it('falls back to the default mode on garbage from/to/window', () => {
115
+ expect(parseTimeMode(sp({ from: 'abc', to: 'def' }), true)).toEqual(live(DEFAULT_LIVE_WIDTH_MS))
116
+ expect(parseTimeMode(sp({ from: '1.5', to: '9.5' }), true)).toEqual(live(DEFAULT_LIVE_WIDTH_MS))
117
+ expect(parseTimeMode(sp({ window: 'abc' }), true)).toEqual(live(DEFAULT_LIVE_WIDTH_MS))
118
+ expect(parseTimeMode(sp({ window: '0' }), true)).toEqual(live(DEFAULT_LIVE_WIDTH_MS))
119
+ expect(parseTimeMode(sp({ window: '-5' }), true)).toEqual(live(DEFAULT_LIVE_WIDTH_MS))
120
+ })
121
+
122
+ it('ignores from/to/window entirely when the source is not retained', () => {
123
+ const from = 1_700_000_000_000
124
+ expect(
125
+ parseTimeMode(sp({ from: String(from), to: String(from + DAY_MS), window: 'all' }), false),
126
+ ).toEqual(live(DEFAULT_LIVE_WIDTH_MS))
127
+ })
128
+ })
129
+
130
+ describe('writeTimelineParams', () => {
131
+ const retainedOpts = { isRetained: true, requiresNamespaceFilter: false as boolean | undefined }
132
+
133
+ it('writes no params for the pristine default state', () => {
134
+ const written = writeTimelineParams(new URLSearchParams(), defaultState, retainedOpts)
135
+ expect(written.toString()).toBe('')
136
+ })
137
+
138
+ it('writes exactly the expected keys for a fully non-default state', () => {
139
+ const state: PersistedTimelineState = {
140
+ viewMode: 'list',
141
+ mode: live(6 * 60 * 60 * 1000),
142
+ showDeleted: false,
143
+ pinnedOnly: true,
144
+ search: 'nginx',
145
+ activityFilter: ['warnings', 'unhealthy'],
146
+ kindFilter: ['Pod', 'Deployment'],
147
+ grouping: 'owner',
148
+ sort: 'recent',
149
+ selectedEventId: 'evt-42',
150
+ }
151
+ const written = writeTimelineParams(new URLSearchParams(), state, retainedOpts)
152
+ expect(sortedEntries(written)).toEqual([
153
+ ['activity', 'warnings,unhealthy'],
154
+ ['deleted', '0'],
155
+ ['event', 'evt-42'],
156
+ ['grouping', 'owner'],
157
+ ['kinds', 'Pod,Deployment'],
158
+ ['pinnedOnly', '1'],
159
+ ['q', 'nginx'],
160
+ ['sort', 'recent'],
161
+ ['view', 'list'],
162
+ ['window', '21600000'],
163
+ ])
164
+ })
165
+
166
+ it('encodes a frozen window as from/to and omits window', () => {
167
+ const from = 1_700_000_000_000
168
+ const to = from + 2 * DAY_MS
169
+ const written = writeTimelineParams(new URLSearchParams(), { ...defaultState, mode: frozen(from, to) }, retainedOpts)
170
+ expect(written.get('from')).toBe(String(from))
171
+ expect(written.get('to')).toBe(String(to))
172
+ expect(written.has('window')).toBe(false)
173
+ })
174
+
175
+ it('preserves foreign params and strips the legacy filter seed', () => {
176
+ const base = new URLSearchParams({ tab: 'topology', filter: 'warnings' })
177
+ const written = writeTimelineParams(base, defaultState, retainedOpts)
178
+ expect(written.get('tab')).toBe('topology')
179
+ expect(written.has('filter')).toBe(false)
180
+ })
181
+
182
+ it('does not persist the list view a large cluster forces', () => {
183
+ const written = writeTimelineParams(new URLSearchParams(), { ...defaultState, viewMode: 'list' }, {
184
+ isRetained: true,
185
+ requiresNamespaceFilter: true,
186
+ })
187
+ expect(written.has('view')).toBe(false)
188
+ })
189
+ })
190
+
191
+ describe('parse(write(state)) round-trip', () => {
192
+ const opts = { isRetained: true, requiresNamespaceFilter: false, maxRangeDays: undefined, hasPins: true }
193
+ const roundTrip = (state: PersistedTimelineState) => {
194
+ const written = writeTimelineParams(new URLSearchParams(), state, {
195
+ isRetained: opts.isRetained,
196
+ requiresNamespaceFilter: opts.requiresNamespaceFilter,
197
+ })
198
+ return parseState(new URLSearchParams(written.toString()), opts)
199
+ }
200
+
201
+ it('round-trips the pristine default state', () => {
202
+ expect(roundTrip(defaultState)).toEqual(defaultState)
203
+ })
204
+
205
+ it('round-trips a live preset with every control set', () => {
206
+ const state: PersistedTimelineState = {
207
+ viewMode: 'list',
208
+ mode: live(6 * 60 * 60 * 1000),
209
+ showDeleted: false,
210
+ pinnedOnly: true,
211
+ search: 'nginx',
212
+ activityFilter: ['warnings', 'unhealthy'],
213
+ kindFilter: ['Pod', 'Deployment'],
214
+ grouping: 'owner',
215
+ sort: 'recent',
216
+ selectedEventId: 'evt-42',
217
+ }
218
+ expect(roundTrip(state)).toEqual(state)
219
+ })
220
+
221
+ it('round-trips a frozen window with a selected event', () => {
222
+ const from = 1_700_000_000_000
223
+ const state: PersistedTimelineState = {
224
+ ...defaultState,
225
+ mode: frozen(from, from + 2 * DAY_MS),
226
+ selectedEventId: 'evt-frozen',
227
+ }
228
+ expect(roundTrip(state)).toEqual(state)
229
+ })
230
+
231
+ it('round-trips a whole-span live window and a search with a space', () => {
232
+ const state: PersistedTimelineState = {
233
+ ...defaultState,
234
+ mode: live(DEFAULT_LIVE_WIDTH_MS, true),
235
+ search: 'foo bar',
236
+ activityFilter: ['changes'],
237
+ sort: 'name',
238
+ }
239
+ expect(roundTrip(state)).toEqual(state)
240
+ })
241
+ })
242
+
243
+ describe('timeModeEqual', () => {
244
+ it('is reflexive for live and frozen modes', () => {
245
+ expect(timeModeEqual(live(DEFAULT_LIVE_WIDTH_MS), live(DEFAULT_LIVE_WIDTH_MS))).toBe(true)
246
+ expect(timeModeEqual(frozen(1, 2), frozen(1, 2))).toBe(true)
247
+ })
248
+
249
+ it('treats the whole-span all flag as significant', () => {
250
+ expect(timeModeEqual(live(DEFAULT_LIVE_WIDTH_MS), live(DEFAULT_LIVE_WIDTH_MS, true))).toBe(false)
251
+ expect(timeModeEqual(live(DEFAULT_LIVE_WIDTH_MS, true), live(DEFAULT_LIVE_WIDTH_MS, true))).toBe(true)
252
+ })
253
+
254
+ it('is false across the live/frozen boundary', () => {
255
+ expect(timeModeEqual(live(DEFAULT_LIVE_WIDTH_MS), frozen(0, DEFAULT_LIVE_WIDTH_MS))).toBe(false)
256
+ })
257
+
258
+ it('distinguishes different widths and different frozen windows', () => {
259
+ expect(timeModeEqual(live(DEFAULT_LIVE_WIDTH_MS), live(2 * DEFAULT_LIVE_WIDTH_MS))).toBe(false)
260
+ expect(timeModeEqual(frozen(1, 2), frozen(1, 3))).toBe(false)
261
+ })
262
+ })
263
+
264
+ describe('onlyHighFreqDiffer (history replace vs push)', () => {
265
+ it('replaces when only a high-frequency key differs (window pan/zoom, search, drawer event)', () => {
266
+ expect(onlyHighFreqDiffer('window=3600000', 'window=7200000')).toBe(true)
267
+ expect(onlyHighFreqDiffer('q=a', 'q=b')).toBe(true)
268
+ expect(onlyHighFreqDiffer('event=evt-1', 'event=evt-2')).toBe(true)
269
+ })
270
+
271
+ it('replaces on a live<->frozen transition — every time-window key is high-frequency', () => {
272
+ expect(onlyHighFreqDiffer('window=7200000', 'from=1&to=2')).toBe(true)
273
+ expect(onlyHighFreqDiffer('', 'from=1&to=2')).toBe(true)
274
+ })
275
+
276
+ it('pushes when a discrete control changes', () => {
277
+ expect(onlyHighFreqDiffer('view=list', 'view=swimlane')).toBe(false)
278
+ expect(onlyHighFreqDiffer('', 'grouping=owner')).toBe(false)
279
+ expect(onlyHighFreqDiffer('', 'sort=recent')).toBe(false)
280
+ expect(onlyHighFreqDiffer('', 'activity=warnings')).toBe(false)
281
+ expect(onlyHighFreqDiffer('', 'kinds=Pod')).toBe(false)
282
+ expect(onlyHighFreqDiffer('', 'deleted=0')).toBe(false)
283
+ expect(onlyHighFreqDiffer('', 'pinnedOnly=1')).toBe(false)
284
+ })
285
+
286
+ it('pushes when a diff mixes a high-frequency and a discrete key', () => {
287
+ expect(onlyHighFreqDiffer('', 'window=7200000&grouping=owner')).toBe(false)
288
+ })
289
+
290
+ it('is not replace-worthy when nothing differs', () => {
291
+ expect(onlyHighFreqDiffer('window=3600000', 'window=3600000')).toBe(false)
292
+ expect(onlyHighFreqDiffer('', '')).toBe(false)
293
+ })
294
+ })
@@ -16,6 +16,7 @@ import { SEVERITY_BADGE } from '@skyhook-io/k8s-ui/utils/badge-colors'
16
16
  import type { AddonMode } from './TrafficView'
17
17
  import { getNamespaceColor } from '../../utils/traffic-colors'
18
18
  import { Tooltip } from '../ui/Tooltip'
19
+ import { Input } from '@skyhook-io/k8s-ui'
19
20
 
20
21
  // Connection threshold options
21
22
  const CONNECTION_THRESHOLDS = [
@@ -398,8 +399,7 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
398
399
  {(l7Protocol === 'all' || l7Protocol === 'DNS') && (
399
400
  <div>
400
401
  <div className="text-[10px] text-theme-text-tertiary mb-1">DNS Query</div>
401
- <input
402
- type="text"
402
+ <Input
403
403
  value={dnsPattern}
404
404
  onChange={(e) => setDnsPattern(e.target.value)}
405
405
  placeholder="e.g. example.com"
@@ -958,7 +958,11 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
958
958
  }
959
959
  }, [sourcesData, sourcesLoading])
960
960
 
961
- // Shared connection handler — used by auto-connect and retry buttons
961
+ // Shared connection handler — used by auto-connect and retry buttons.
962
+ // Deliberately does NOT reset hasAutoConnectedRef on failure: that ref gates
963
+ // the auto-connect effect, and re-arming it would re-fire auto-connect on
964
+ // every failed attempt (unbounded loop). Retries come from the explicit
965
+ // Retry button, which calls handleConnect directly.
962
966
  const handleConnect = useCallback(() => {
963
967
  setIsConnecting(true)
964
968
  setConnectionError(null)
@@ -969,18 +973,18 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
969
973
  setIsConnecting(false)
970
974
  if (!data.connected && data.error) {
971
975
  setConnectionError(data.error)
972
- hasAutoConnectedRef.current = false // allow retry
973
976
  }
974
977
  },
975
978
  onError: (error) => {
976
979
  setIsConnecting(false)
977
980
  setConnectionError(error.message)
978
- hasAutoConnectedRef.current = false // allow retry
979
981
  },
980
982
  })
981
983
  }, [connectMutation, queryClient])
982
984
 
983
- // Auto-connect when source is detected
985
+ // Auto-connect once when a source is first detected. Strictly one-shot per
986
+ // mount / cluster (the ref resets on cluster change); failures surface a
987
+ // manual Retry rather than re-arming this effect.
984
988
  useEffect(() => {
985
989
  if (wizardState === 'ready' && !hasAutoConnectedRef.current && !isConnecting) {
986
990
  hasAutoConnectedRef.current = true
@@ -3,6 +3,7 @@ import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
3
3
  import { Search, X, ChevronRight } from 'lucide-react'
4
4
  import { clsx } from 'clsx'
5
5
  import { useCommandItems, bestScore, type CommandItem, type CommandItemCallbacks } from './command-items'
6
+ import { Input } from '@skyhook-io/k8s-ui'
6
7
 
7
8
  interface CommandPaletteProps extends CommandItemCallbacks {
8
9
  onClose: () => void
@@ -141,9 +142,8 @@ export function CommandPalette({ onClose, isOpen = true, ...callbacks }: Command
141
142
  {/* Search input */}
142
143
  <div className="flex items-center gap-3 px-4 py-3 border-b border-theme-border">
143
144
  <Search className="w-5 h-5 text-theme-text-secondary shrink-0" />
144
- <input
145
+ <Input
145
146
  ref={inputRef}
146
- type="text"
147
147
  value={query}
148
148
  onChange={(e) => setQuery(e.target.value)}
149
149
  onKeyDown={handleKeyDown}