clawport-ui 0.1.0

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 (132) hide show
  1. package/.env.example +35 -0
  2. package/BRANDING.md +131 -0
  3. package/CLAUDE.md +252 -0
  4. package/README.md +262 -0
  5. package/SETUP.md +337 -0
  6. package/app/agents/[id]/page.tsx +727 -0
  7. package/app/api/agents/route.ts +12 -0
  8. package/app/api/chat/[id]/route.ts +139 -0
  9. package/app/api/cron-runs/route.ts +13 -0
  10. package/app/api/crons/route.ts +12 -0
  11. package/app/api/kanban/chat/[id]/route.ts +119 -0
  12. package/app/api/kanban/chat-history/[ticketId]/route.ts +36 -0
  13. package/app/api/memory/route.ts +12 -0
  14. package/app/api/transcribe/route.ts +37 -0
  15. package/app/api/tts/route.ts +42 -0
  16. package/app/chat/[id]/page.tsx +10 -0
  17. package/app/chat/page.tsx +200 -0
  18. package/app/crons/page.tsx +870 -0
  19. package/app/docs/page.tsx +399 -0
  20. package/app/favicon.ico +0 -0
  21. package/app/globals.css +692 -0
  22. package/app/kanban/page.tsx +327 -0
  23. package/app/layout.tsx +45 -0
  24. package/app/memory/page.tsx +685 -0
  25. package/app/page.tsx +817 -0
  26. package/app/providers.tsx +37 -0
  27. package/app/settings/page.tsx +901 -0
  28. package/app/settings-provider.tsx +209 -0
  29. package/components/AgentAvatar.tsx +54 -0
  30. package/components/AgentNode.tsx +122 -0
  31. package/components/Breadcrumbs.tsx +126 -0
  32. package/components/DynamicFavicon.tsx +62 -0
  33. package/components/ErrorState.tsx +97 -0
  34. package/components/FeedView.tsx +494 -0
  35. package/components/GlobalSearch.tsx +571 -0
  36. package/components/GridView.tsx +532 -0
  37. package/components/ManorMap.tsx +157 -0
  38. package/components/MobileSidebar.tsx +251 -0
  39. package/components/NavLinks.tsx +271 -0
  40. package/components/OnboardingWizard.tsx +1067 -0
  41. package/components/Sidebar.tsx +115 -0
  42. package/components/ThemeToggle.tsx +108 -0
  43. package/components/chat/AgentList.tsx +537 -0
  44. package/components/chat/ConversationView.tsx +1047 -0
  45. package/components/chat/FileAttachment.tsx +140 -0
  46. package/components/chat/MediaPreview.tsx +111 -0
  47. package/components/chat/VoiceMessage.tsx +139 -0
  48. package/components/crons/PipelineGraph.tsx +327 -0
  49. package/components/crons/WeeklySchedule.tsx +630 -0
  50. package/components/docs/AgentsSection.tsx +209 -0
  51. package/components/docs/ApiReferenceSection.tsx +256 -0
  52. package/components/docs/ArchitectureSection.tsx +221 -0
  53. package/components/docs/ComponentsSection.tsx +253 -0
  54. package/components/docs/CronSystemSection.tsx +235 -0
  55. package/components/docs/DocSection.tsx +346 -0
  56. package/components/docs/GettingStartedSection.tsx +169 -0
  57. package/components/docs/ThemingSection.tsx +257 -0
  58. package/components/docs/TroubleshootingSection.tsx +200 -0
  59. package/components/kanban/AgentPicker.tsx +321 -0
  60. package/components/kanban/CreateTicketModal.tsx +333 -0
  61. package/components/kanban/KanbanBoard.tsx +70 -0
  62. package/components/kanban/KanbanColumn.tsx +166 -0
  63. package/components/kanban/TicketCard.tsx +245 -0
  64. package/components/kanban/TicketDetailPanel.tsx +850 -0
  65. package/components/ui/badge.tsx +48 -0
  66. package/components/ui/button.tsx +64 -0
  67. package/components/ui/card.tsx +92 -0
  68. package/components/ui/dialog.tsx +158 -0
  69. package/components/ui/scroll-area.tsx +58 -0
  70. package/components/ui/separator.tsx +28 -0
  71. package/components/ui/skeleton.tsx +27 -0
  72. package/components/ui/tabs.tsx +91 -0
  73. package/components/ui/tooltip.tsx +57 -0
  74. package/components.json +23 -0
  75. package/docs/API.md +648 -0
  76. package/docs/COMPONENTS.md +1059 -0
  77. package/docs/THEMING.md +795 -0
  78. package/lib/agents-registry.ts +35 -0
  79. package/lib/agents.json +282 -0
  80. package/lib/agents.test.ts +367 -0
  81. package/lib/agents.ts +32 -0
  82. package/lib/anthropic.test.ts +422 -0
  83. package/lib/anthropic.ts +220 -0
  84. package/lib/api-error.ts +16 -0
  85. package/lib/audio-recorder.test.ts +72 -0
  86. package/lib/audio-recorder.ts +169 -0
  87. package/lib/conversations.test.ts +331 -0
  88. package/lib/conversations.ts +117 -0
  89. package/lib/cron-pipelines.test.ts +69 -0
  90. package/lib/cron-pipelines.ts +58 -0
  91. package/lib/cron-runs.test.ts +118 -0
  92. package/lib/cron-runs.ts +67 -0
  93. package/lib/cron-utils.test.ts +222 -0
  94. package/lib/cron-utils.ts +160 -0
  95. package/lib/crons.test.ts +502 -0
  96. package/lib/crons.ts +114 -0
  97. package/lib/env.test.ts +44 -0
  98. package/lib/env.ts +14 -0
  99. package/lib/kanban/automation.test.ts +245 -0
  100. package/lib/kanban/automation.ts +143 -0
  101. package/lib/kanban/chat-store.test.ts +149 -0
  102. package/lib/kanban/chat-store.ts +81 -0
  103. package/lib/kanban/store.test.ts +238 -0
  104. package/lib/kanban/store.ts +98 -0
  105. package/lib/kanban/types.ts +50 -0
  106. package/lib/kanban/useAgentWork.ts +78 -0
  107. package/lib/memory.ts +45 -0
  108. package/lib/multimodal.test.ts +219 -0
  109. package/lib/multimodal.ts +68 -0
  110. package/lib/pipeline.integration.test.ts +343 -0
  111. package/lib/sanitize.ts +194 -0
  112. package/lib/settings.test.ts +137 -0
  113. package/lib/settings.ts +94 -0
  114. package/lib/styles.ts +24 -0
  115. package/lib/themes.ts +9 -0
  116. package/lib/transcribe.test.ts +141 -0
  117. package/lib/transcribe.ts +111 -0
  118. package/lib/types.ts +66 -0
  119. package/lib/utils.ts +6 -0
  120. package/lib/validation.test.ts +132 -0
  121. package/lib/validation.ts +80 -0
  122. package/next.config.ts +7 -0
  123. package/package.json +56 -0
  124. package/postcss.config.mjs +7 -0
  125. package/public/file.svg +1 -0
  126. package/public/globe.svg +1 -0
  127. package/public/next.svg +1 -0
  128. package/public/vercel.svg +1 -0
  129. package/public/window.svg +1 -0
  130. package/scripts/setup.mjs +215 -0
  131. package/tsconfig.json +34 -0
  132. package/vitest.config.ts +17 -0
@@ -0,0 +1,571 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import {
6
+ Search,
7
+ Map,
8
+ MessageSquare,
9
+ Clock,
10
+ Brain,
11
+ Bot,
12
+ Timer,
13
+ Settings,
14
+ } from 'lucide-react';
15
+ import type { Agent, CronJob } from '@/lib/types';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Types
19
+ // ---------------------------------------------------------------------------
20
+
21
+ interface SearchResult {
22
+ id: string;
23
+ label: string;
24
+ subtitle?: string;
25
+ icon: React.ReactNode;
26
+ href: string;
27
+ category: 'Agents' | 'Pages' | 'Crons';
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Static pages
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const STATIC_PAGES: SearchResult[] = [
35
+ { id: 'page-map', label: 'Map', icon: <Map size={16} />, href: '/', category: 'Pages' },
36
+ { id: 'page-messages', label: 'Messages', icon: <MessageSquare size={16} />, href: '/chat', category: 'Pages' },
37
+ { id: 'page-crons', label: 'Crons', icon: <Clock size={16} />, href: '/crons', category: 'Pages' },
38
+ { id: 'page-memory', label: 'Memory', icon: <Brain size={16} />, href: '/memory', category: 'Pages' },
39
+ { id: 'page-settings', label: 'Settings', icon: <Settings size={16} />, href: '/settings', category: 'Pages' },
40
+ ];
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Simple fuzzy match — case-insensitive substring
44
+ // ---------------------------------------------------------------------------
45
+
46
+ function fuzzyMatch(query: string, target: string): boolean {
47
+ const q = query.toLowerCase();
48
+ const t = target.toLowerCase();
49
+ // Substring match
50
+ if (t.includes(q)) return true;
51
+ // Check if all characters appear in order (fuzzy)
52
+ let qi = 0;
53
+ for (let ti = 0; ti < t.length && qi < q.length; ti++) {
54
+ if (t[ti] === q[qi]) qi++;
55
+ }
56
+ return qi === q.length;
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Search trigger button (used in sidebar)
61
+ // ---------------------------------------------------------------------------
62
+
63
+ export function SearchTrigger({ onClick }: { onClick: () => void }) {
64
+ return (
65
+ <button
66
+ onClick={onClick}
67
+ className="nav-item focus-ring"
68
+ aria-label="Open search (Cmd+K)"
69
+ style={{
70
+ width: '100%',
71
+ display: 'flex',
72
+ alignItems: 'center',
73
+ gap: '8px',
74
+ height: '36px',
75
+ padding: '0 12px',
76
+ borderRadius: '8px',
77
+ border: '1px solid var(--separator)',
78
+ background: 'var(--fill-quaternary)',
79
+ color: 'var(--text-tertiary)',
80
+ fontSize: '13px',
81
+ cursor: 'pointer',
82
+ transition: 'all 100ms var(--ease-smooth)',
83
+ }}
84
+ >
85
+ <Search size={14} style={{ flexShrink: 0, opacity: 0.7 }} />
86
+ <span style={{ flex: 1, textAlign: 'left' }}>Search...</span>
87
+ <kbd
88
+ style={{
89
+ fontSize: '11px',
90
+ fontFamily: 'var(--font-mono)',
91
+ padding: '1px 5px',
92
+ borderRadius: '4px',
93
+ background: 'var(--fill-tertiary)',
94
+ color: 'var(--text-quaternary)',
95
+ border: '1px solid var(--separator)',
96
+ lineHeight: '16px',
97
+ }}
98
+ >
99
+ {'\u2318'}K
100
+ </kbd>
101
+ </button>
102
+ );
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // GlobalSearch modal
107
+ // ---------------------------------------------------------------------------
108
+
109
+ export function GlobalSearch() {
110
+ const [open, setOpen] = useState(false);
111
+ const [query, setQuery] = useState('');
112
+ const [activeIndex, setActiveIndex] = useState(0);
113
+ const [agents, setAgents] = useState<Agent[]>([]);
114
+ const [crons, setCrons] = useState<CronJob[]>([]);
115
+ const inputRef = useRef<HTMLInputElement>(null);
116
+ const listRef = useRef<HTMLDivElement>(null);
117
+ const router = useRouter();
118
+
119
+ // -----------------------------------------------------------------------
120
+ // Keyboard shortcut: Cmd+K / Ctrl+K
121
+ // -----------------------------------------------------------------------
122
+ useEffect(() => {
123
+ function handleKeyDown(e: KeyboardEvent) {
124
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
125
+ e.preventDefault();
126
+ setOpen((prev) => !prev);
127
+ }
128
+ }
129
+ window.addEventListener('keydown', handleKeyDown);
130
+ return () => window.removeEventListener('keydown', handleKeyDown);
131
+ }, []);
132
+
133
+ // -----------------------------------------------------------------------
134
+ // Custom event: open search from sidebar trigger buttons
135
+ // -----------------------------------------------------------------------
136
+ useEffect(() => {
137
+ function handleOpenSearch() {
138
+ setOpen(true);
139
+ }
140
+ window.addEventListener('clawport:open-search', handleOpenSearch);
141
+ return () => window.removeEventListener('clawport:open-search', handleOpenSearch);
142
+ }, []);
143
+
144
+ // -----------------------------------------------------------------------
145
+ // Fetch data when modal opens
146
+ // -----------------------------------------------------------------------
147
+ useEffect(() => {
148
+ if (!open) return;
149
+ // Reset state
150
+ setQuery('');
151
+ setActiveIndex(0);
152
+ // Fetch agents
153
+ fetch('/api/agents')
154
+ .then((r) => {
155
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
156
+ return r.json();
157
+ })
158
+ .then((data: unknown) => {
159
+ if (Array.isArray(data)) setAgents(data as Agent[]);
160
+ })
161
+ .catch(() => setAgents([]));
162
+ // Fetch crons
163
+ fetch('/api/crons')
164
+ .then((r) => {
165
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
166
+ return r.json();
167
+ })
168
+ .then((data: unknown) => {
169
+ if (Array.isArray(data)) setCrons(data as CronJob[]);
170
+ })
171
+ .catch(() => setCrons([]));
172
+ }, [open]);
173
+
174
+ // -----------------------------------------------------------------------
175
+ // Focus input when opened
176
+ // -----------------------------------------------------------------------
177
+ useEffect(() => {
178
+ if (open) {
179
+ // Small delay to ensure the input is mounted
180
+ requestAnimationFrame(() => {
181
+ inputRef.current?.focus();
182
+ });
183
+ }
184
+ }, [open]);
185
+
186
+ // -----------------------------------------------------------------------
187
+ // Prevent body scroll
188
+ // -----------------------------------------------------------------------
189
+ useEffect(() => {
190
+ if (open) {
191
+ document.body.style.overflow = 'hidden';
192
+ } else {
193
+ document.body.style.overflow = '';
194
+ }
195
+ return () => {
196
+ document.body.style.overflow = '';
197
+ };
198
+ }, [open]);
199
+
200
+ // -----------------------------------------------------------------------
201
+ // Build filtered results
202
+ // -----------------------------------------------------------------------
203
+ const results = useMemo(() => {
204
+ const all: SearchResult[] = [];
205
+
206
+ // Agents
207
+ agents.forEach((a) => {
208
+ all.push({
209
+ id: `agent-${a.id}`,
210
+ label: a.name,
211
+ subtitle: a.title,
212
+ icon: <Bot size={16} style={{ color: a.color }} />,
213
+ href: `/chat?agent=${a.id}`,
214
+ category: 'Agents',
215
+ });
216
+ });
217
+
218
+ // Static pages
219
+ all.push(...STATIC_PAGES);
220
+
221
+ // Crons
222
+ crons.forEach((c) => {
223
+ all.push({
224
+ id: `cron-${c.id}`,
225
+ label: c.name,
226
+ subtitle: c.schedule,
227
+ icon: <Timer size={16} />,
228
+ href: '/crons',
229
+ category: 'Crons',
230
+ });
231
+ });
232
+
233
+ if (!query.trim()) return all;
234
+
235
+ return all.filter(
236
+ (r) =>
237
+ fuzzyMatch(query, r.label) ||
238
+ (r.subtitle && fuzzyMatch(query, r.subtitle))
239
+ );
240
+ }, [query, agents, crons]);
241
+
242
+ // -----------------------------------------------------------------------
243
+ // Group results by category
244
+ // -----------------------------------------------------------------------
245
+ const grouped = useMemo(() => {
246
+ const groups: { category: string; items: SearchResult[] }[] = [];
247
+ const categoryOrder = ['Agents', 'Pages', 'Crons'];
248
+ for (const cat of categoryOrder) {
249
+ const items = results.filter((r) => r.category === cat);
250
+ if (items.length > 0) {
251
+ groups.push({ category: cat, items });
252
+ }
253
+ }
254
+ return groups;
255
+ }, [results]);
256
+
257
+ // Flat list for keyboard nav
258
+ const flatResults = useMemo(() => grouped.flatMap((g) => g.items), [grouped]);
259
+
260
+ // -----------------------------------------------------------------------
261
+ // Navigation
262
+ // -----------------------------------------------------------------------
263
+ const navigate = useCallback(
264
+ (result: SearchResult) => {
265
+ setOpen(false);
266
+ router.push(result.href);
267
+ },
268
+ [router]
269
+ );
270
+
271
+ // -----------------------------------------------------------------------
272
+ // Keyboard handling inside the modal
273
+ // -----------------------------------------------------------------------
274
+ const handleKeyDown = useCallback(
275
+ (e: React.KeyboardEvent) => {
276
+ if (e.key === 'Escape') {
277
+ e.preventDefault();
278
+ setOpen(false);
279
+ return;
280
+ }
281
+ if (e.key === 'ArrowDown') {
282
+ e.preventDefault();
283
+ setActiveIndex((prev) => Math.min(prev + 1, flatResults.length - 1));
284
+ return;
285
+ }
286
+ if (e.key === 'ArrowUp') {
287
+ e.preventDefault();
288
+ setActiveIndex((prev) => Math.max(prev - 1, 0));
289
+ return;
290
+ }
291
+ if (e.key === 'Enter') {
292
+ e.preventDefault();
293
+ if (flatResults[activeIndex]) {
294
+ navigate(flatResults[activeIndex]);
295
+ }
296
+ return;
297
+ }
298
+ },
299
+ [activeIndex, flatResults, navigate]
300
+ );
301
+
302
+ // Reset active index when results change
303
+ useEffect(() => {
304
+ setActiveIndex(0);
305
+ }, [query]);
306
+
307
+ // Scroll active item into view
308
+ useEffect(() => {
309
+ if (!listRef.current) return;
310
+ const activeEl = listRef.current.querySelector('[data-active="true"]');
311
+ if (activeEl) {
312
+ activeEl.scrollIntoView({ block: 'nearest' });
313
+ }
314
+ }, [activeIndex]);
315
+
316
+ if (!open) return null;
317
+
318
+ let flatIndex = 0;
319
+
320
+ return (
321
+ <div
322
+ style={{
323
+ position: 'fixed',
324
+ inset: 0,
325
+ zIndex: 100,
326
+ display: 'flex',
327
+ alignItems: 'flex-start',
328
+ justifyContent: 'center',
329
+ paddingTop: '20vh',
330
+ }}
331
+ >
332
+ {/* Backdrop */}
333
+ <div
334
+ style={{
335
+ position: 'absolute',
336
+ inset: 0,
337
+ background: 'rgba(0,0,0,0.5)',
338
+ backdropFilter: 'blur(4px)',
339
+ WebkitBackdropFilter: 'blur(4px)',
340
+ }}
341
+ onClick={() => setOpen(false)}
342
+ aria-hidden="true"
343
+ />
344
+
345
+ {/* Modal */}
346
+ <div
347
+ role="dialog"
348
+ aria-modal="true"
349
+ aria-label="Search ClawPort"
350
+ className="animate-scale-in"
351
+ onKeyDown={handleKeyDown}
352
+ style={{
353
+ position: 'relative',
354
+ width: '100%',
355
+ maxWidth: '560px',
356
+ margin: '0 16px',
357
+ borderRadius: 'var(--radius-xl)',
358
+ background: 'var(--material-regular)',
359
+ border: '1px solid var(--separator)',
360
+ boxShadow: 'var(--shadow-overlay)',
361
+ backdropFilter: 'blur(40px) saturate(180%)',
362
+ WebkitBackdropFilter: 'blur(40px) saturate(180%)',
363
+ overflow: 'hidden',
364
+ display: 'flex',
365
+ flexDirection: 'column',
366
+ maxHeight: '480px',
367
+ }}
368
+ >
369
+ {/* Search input */}
370
+ <div
371
+ style={{
372
+ display: 'flex',
373
+ alignItems: 'center',
374
+ gap: '10px',
375
+ padding: '14px 16px',
376
+ borderBottom: '1px solid var(--separator)',
377
+ }}
378
+ >
379
+ <Search
380
+ size={18}
381
+ style={{ color: 'var(--text-tertiary)', flexShrink: 0 }}
382
+ aria-hidden="true"
383
+ />
384
+ <input
385
+ ref={inputRef}
386
+ type="text"
387
+ value={query}
388
+ onChange={(e) => setQuery(e.target.value)}
389
+ placeholder="Search ClawPort..."
390
+ aria-label="Search ClawPort"
391
+ style={{
392
+ flex: 1,
393
+ background: 'transparent',
394
+ border: 'none',
395
+ outline: 'none',
396
+ fontSize: '15px',
397
+ color: 'var(--text-primary)',
398
+ fontFamily: 'inherit',
399
+ }}
400
+ />
401
+ <kbd
402
+ style={{
403
+ fontSize: '11px',
404
+ fontFamily: 'var(--font-mono)',
405
+ padding: '2px 6px',
406
+ borderRadius: '4px',
407
+ background: 'var(--fill-quaternary)',
408
+ color: 'var(--text-quaternary)',
409
+ border: '1px solid var(--separator)',
410
+ lineHeight: '16px',
411
+ }}
412
+ >
413
+ esc
414
+ </kbd>
415
+ </div>
416
+
417
+ {/* Results */}
418
+ <div
419
+ ref={listRef}
420
+ role="listbox"
421
+ aria-label="Search results"
422
+ style={{
423
+ flex: 1,
424
+ overflowY: 'auto',
425
+ padding: '8px',
426
+ }}
427
+ >
428
+ {flatResults.length === 0 && query.trim() && (
429
+ <div
430
+ style={{
431
+ padding: '24px 16px',
432
+ textAlign: 'center',
433
+ color: 'var(--text-tertiary)',
434
+ fontSize: '13px',
435
+ }}
436
+ >
437
+ No results for &lsquo;{query}&rsquo;
438
+ </div>
439
+ )}
440
+
441
+ {grouped.map((group) => (
442
+ <div key={group.category} style={{ marginBottom: '4px' }}>
443
+ {/* Category header */}
444
+ <div
445
+ style={{
446
+ fontSize: '11px',
447
+ fontWeight: 600,
448
+ letterSpacing: '0.06em',
449
+ textTransform: 'uppercase',
450
+ color: 'var(--text-tertiary)',
451
+ padding: '6px 8px 4px',
452
+ }}
453
+ >
454
+ {group.category}
455
+ </div>
456
+
457
+ {/* Items */}
458
+ {group.items.map((item) => {
459
+ const currentIndex = flatIndex++;
460
+ const isActive = currentIndex === activeIndex;
461
+
462
+ return (
463
+ <button
464
+ key={item.id}
465
+ role="option"
466
+ aria-selected={isActive}
467
+ data-active={isActive}
468
+ onClick={() => navigate(item)}
469
+ onMouseEnter={() => setActiveIndex(currentIndex)}
470
+ className="focus-ring"
471
+ style={{
472
+ display: 'flex',
473
+ alignItems: 'center',
474
+ gap: '10px',
475
+ width: '100%',
476
+ minHeight: '44px',
477
+ padding: '8px 10px',
478
+ borderRadius: '8px',
479
+ border: 'none',
480
+ background: isActive
481
+ ? 'var(--accent-fill)'
482
+ : 'transparent',
483
+ cursor: 'pointer',
484
+ textAlign: 'left',
485
+ transition: 'background 80ms var(--ease-smooth)',
486
+ outline: 'none',
487
+ }}
488
+ aria-label={
489
+ item.subtitle
490
+ ? `${item.label} - ${item.subtitle}`
491
+ : item.label
492
+ }
493
+ >
494
+ <span
495
+ style={{
496
+ width: '28px',
497
+ height: '28px',
498
+ display: 'flex',
499
+ alignItems: 'center',
500
+ justifyContent: 'center',
501
+ borderRadius: '6px',
502
+ background: 'var(--fill-quaternary)',
503
+ flexShrink: 0,
504
+ color: 'var(--text-secondary)',
505
+ }}
506
+ >
507
+ {item.icon}
508
+ </span>
509
+ <div style={{ flex: 1, minWidth: 0 }}>
510
+ <div
511
+ style={{
512
+ fontSize: '13px',
513
+ fontWeight: 500,
514
+ color: isActive
515
+ ? 'var(--text-primary)'
516
+ : 'var(--text-primary)',
517
+ overflow: 'hidden',
518
+ textOverflow: 'ellipsis',
519
+ whiteSpace: 'nowrap',
520
+ }}
521
+ >
522
+ {item.label}
523
+ </div>
524
+ {item.subtitle && (
525
+ <div
526
+ style={{
527
+ fontSize: '11px',
528
+ color: 'var(--text-tertiary)',
529
+ overflow: 'hidden',
530
+ textOverflow: 'ellipsis',
531
+ whiteSpace: 'nowrap',
532
+ }}
533
+ >
534
+ {item.subtitle}
535
+ </div>
536
+ )}
537
+ </div>
538
+ </button>
539
+ );
540
+ })}
541
+ </div>
542
+ ))}
543
+ </div>
544
+
545
+ {/* Footer with keyboard hints */}
546
+ <div
547
+ style={{
548
+ display: 'flex',
549
+ alignItems: 'center',
550
+ justifyContent: 'center',
551
+ gap: '16px',
552
+ padding: '8px 16px',
553
+ borderTop: '1px solid var(--separator)',
554
+ fontSize: '11px',
555
+ color: 'var(--text-quaternary)',
556
+ }}
557
+ >
558
+ <span>
559
+ <kbd style={{ fontFamily: 'var(--font-mono)' }}>{'\u2191\u2193'}</kbd> Navigate
560
+ </span>
561
+ <span>
562
+ <kbd style={{ fontFamily: 'var(--font-mono)' }}>{'\u21B5'}</kbd> Open
563
+ </span>
564
+ <span>
565
+ <kbd style={{ fontFamily: 'var(--font-mono)' }}>esc</kbd> Close
566
+ </span>
567
+ </div>
568
+ </div>
569
+ </div>
570
+ );
571
+ }