rufloui 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/'1' +0 -0
  2. package/.env.example +46 -0
  3. package/CHANGELOG.md +87 -0
  4. package/CLAUDE.md +287 -0
  5. package/LICENSE +21 -0
  6. package/README.md +316 -0
  7. package/Webhooks) +0 -0
  8. package/docs/plans/2026-03-11-github-webhooks.md +957 -0
  9. package/docs/screenshot-swarm-monitor.png +0 -0
  10. package/frontend +0 -0
  11. package/index.html +13 -0
  12. package/package.json +56 -0
  13. package/public/vite.svg +4 -0
  14. package/src/backend/__tests__/webhook-github.test.ts +934 -0
  15. package/src/backend/jsonl-monitor.ts +430 -0
  16. package/src/backend/server.ts +2972 -0
  17. package/src/backend/telegram-bot.ts +511 -0
  18. package/src/backend/webhook-github.ts +350 -0
  19. package/src/frontend/App.tsx +461 -0
  20. package/src/frontend/api.ts +281 -0
  21. package/src/frontend/components/ErrorBoundary.tsx +98 -0
  22. package/src/frontend/components/Layout.tsx +431 -0
  23. package/src/frontend/components/ui/Button.tsx +111 -0
  24. package/src/frontend/components/ui/Card.tsx +51 -0
  25. package/src/frontend/components/ui/StatusBadge.tsx +60 -0
  26. package/src/frontend/main.tsx +63 -0
  27. package/src/frontend/pages/AgentVizPanel.tsx +428 -0
  28. package/src/frontend/pages/AgentsPanel.tsx +445 -0
  29. package/src/frontend/pages/ConfigPanel.tsx +661 -0
  30. package/src/frontend/pages/Dashboard.tsx +482 -0
  31. package/src/frontend/pages/HiveMindPanel.tsx +355 -0
  32. package/src/frontend/pages/HooksPanel.tsx +240 -0
  33. package/src/frontend/pages/LogsPanel.tsx +261 -0
  34. package/src/frontend/pages/MemoryPanel.tsx +444 -0
  35. package/src/frontend/pages/NeuralPanel.tsx +301 -0
  36. package/src/frontend/pages/PerformancePanel.tsx +198 -0
  37. package/src/frontend/pages/SessionsPanel.tsx +428 -0
  38. package/src/frontend/pages/SetupWizard.tsx +181 -0
  39. package/src/frontend/pages/SwarmMonitorPanel.tsx +634 -0
  40. package/src/frontend/pages/SwarmPanel.tsx +322 -0
  41. package/src/frontend/pages/TasksPanel.tsx +535 -0
  42. package/src/frontend/pages/WebhooksPanel.tsx +335 -0
  43. package/src/frontend/pages/WorkflowsPanel.tsx +448 -0
  44. package/src/frontend/store.ts +185 -0
  45. package/src/frontend/styles/global.css +113 -0
  46. package/src/frontend/test-setup.ts +1 -0
  47. package/src/frontend/tour/TourContext.tsx +161 -0
  48. package/src/frontend/tour/tourSteps.ts +181 -0
  49. package/src/frontend/tour/tourStyles.css +116 -0
  50. package/src/frontend/types.ts +239 -0
  51. package/src/frontend/utils/formatTime.test.ts +83 -0
  52. package/src/frontend/utils/formatTime.ts +23 -0
  53. package/tsconfig.json +23 -0
  54. package/vite.config.ts +26 -0
  55. package/vitest.config.ts +17 -0
  56. package/{,+ +0 -0
@@ -0,0 +1,444 @@
1
+ import { useState, useEffect, useCallback, type CSSProperties } from 'react'
2
+ import { useStore } from '@/store'
3
+ import { api } from '@/api'
4
+ import { Card } from '@/components/ui/Card'
5
+ import { Button } from '@/components/ui/Button'
6
+ import { StatusBadge } from '@/components/ui/StatusBadge'
7
+ import { Database, Search, Plus, Trash2, RefreshCw } from 'lucide-react'
8
+ import type { MemoryEntry, MemoryStats } from '@/types'
9
+
10
+ const s = {
11
+ page: { display: 'flex', flexDirection: 'column', gap: 20 } as CSSProperties,
12
+ statsRow: { display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 14 } as CSSProperties,
13
+ statCard: {
14
+ background: 'var(--bg-card)', border: '1px solid var(--border)',
15
+ borderRadius: 'var(--radius-lg)', padding: '16px 20px',
16
+ } as CSSProperties,
17
+ statLabel: { fontSize: 12, color: 'var(--text-muted)', marginBottom: 6 } as CSSProperties,
18
+ statValue: { fontSize: 22, fontWeight: 700, color: 'var(--text-primary)' } as CSSProperties,
19
+ hnswBadge: (on: boolean) => ({
20
+ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12,
21
+ padding: '4px 10px', borderRadius: 'var(--radius)', marginTop: 8,
22
+ background: on ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)',
23
+ color: on ? 'var(--accent-green)' : 'var(--accent-red)',
24
+ }) as CSSProperties,
25
+ searchRow: {
26
+ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap',
27
+ } as CSSProperties,
28
+ field: { display: 'flex', flexDirection: 'column', gap: 4 } as CSSProperties,
29
+ label: { fontSize: 12, color: 'var(--text-muted)' } as CSSProperties,
30
+ input: {
31
+ background: 'var(--bg-primary)', border: '1px solid var(--border)',
32
+ borderRadius: 'var(--radius)', padding: '7px 12px', fontSize: 13,
33
+ color: 'var(--text-primary)', outline: 'none', minWidth: 0,
34
+ } as CSSProperties,
35
+ textarea: {
36
+ background: 'var(--bg-primary)', border: '1px solid var(--border)',
37
+ borderRadius: 'var(--radius)', padding: '7px 12px', fontSize: 13,
38
+ color: 'var(--text-primary)', outline: 'none', resize: 'vertical',
39
+ minHeight: 60, fontFamily: 'inherit',
40
+ } as CSSProperties,
41
+ storeForm: {
42
+ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12,
43
+ } as CSSProperties,
44
+ fullSpan: { gridColumn: '1 / -1' } as CSSProperties,
45
+ table: { width: '100%', borderCollapse: 'collapse' } as CSSProperties,
46
+ th: {
47
+ textAlign: 'left' as const, padding: '10px 14px', fontSize: 12,
48
+ fontWeight: 600, color: 'var(--text-muted)', borderBottom: '1px solid var(--border)',
49
+ textTransform: 'uppercase' as const, letterSpacing: '0.05em',
50
+ } as CSSProperties,
51
+ td: {
52
+ padding: '10px 14px', fontSize: 13, color: 'var(--text-secondary)',
53
+ borderBottom: '1px solid var(--border)',
54
+ } as CSSProperties,
55
+ tag: {
56
+ display: 'inline-block', fontSize: 11, padding: '2px 8px',
57
+ borderRadius: 'var(--radius)', background: 'var(--bg-hover)',
58
+ color: 'var(--text-muted)', marginRight: 4,
59
+ } as CSSProperties,
60
+ nsTabs: { display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12 } as CSSProperties,
61
+ nsTab: (active: boolean) => ({
62
+ padding: '5px 12px', fontSize: 12, borderRadius: 'var(--radius)',
63
+ cursor: 'pointer', border: '1px solid var(--border)',
64
+ background: active ? 'var(--accent-blue)' : 'transparent',
65
+ color: active ? '#fff' : 'var(--text-secondary)',
66
+ transition: 'all var(--transition)',
67
+ }) as CSSProperties,
68
+ expandedValue: {
69
+ padding: 14, background: 'var(--bg-primary)', borderRadius: 'var(--radius)',
70
+ fontSize: 13, color: 'var(--text-primary)', whiteSpace: 'pre-wrap',
71
+ wordBreak: 'break-word', fontFamily: 'monospace', marginTop: 6,
72
+ } as CSSProperties,
73
+ row: { display: 'flex', gap: 10, alignItems: 'center' } as CSSProperties,
74
+ collapsible: {
75
+ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8,
76
+ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 12,
77
+ } as CSSProperties,
78
+ actions: { display: 'flex', gap: 6, alignItems: 'center' } as CSSProperties,
79
+ overlay: {
80
+ position: 'fixed' as const, inset: 0, background: 'rgba(0,0,0,0.6)',
81
+ display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000,
82
+ } as CSSProperties,
83
+ dialog: {
84
+ background: 'var(--bg-card)', border: '1px solid var(--border)',
85
+ borderRadius: 'var(--radius-lg)', padding: 24, minWidth: 340, maxWidth: 480,
86
+ } as CSSProperties,
87
+ dialogTitle: { fontSize: 16, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 16 } as CSSProperties,
88
+ empty: { textAlign: 'center' as const, color: 'var(--text-muted)', padding: 40, fontSize: 14 } as CSSProperties,
89
+ resultItem: {
90
+ padding: '10px 14px', borderBottom: '1px solid var(--border)',
91
+ cursor: 'pointer', transition: 'background var(--transition)',
92
+ } as CSSProperties,
93
+ resultKey: { fontSize: 13, fontWeight: 600, color: 'var(--accent-cyan)' } as CSSProperties,
94
+ resultVal: {
95
+ fontSize: 12, color: 'var(--text-muted)', marginTop: 4,
96
+ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
97
+ } as CSSProperties,
98
+ }
99
+
100
+ function truncate(str: string, len = 80): string {
101
+ return str.length > len ? str.slice(0, len) + '...' : str
102
+ }
103
+
104
+ export default function MemoryPanel() {
105
+ const { memoryEntries, memoryStats, setMemoryEntries, setMemoryStats } = useStore()
106
+
107
+ const [loading, setLoading] = useState(false)
108
+ const [searchQuery, setSearchQuery] = useState('')
109
+ const [searchNs, setSearchNs] = useState('')
110
+ const [searchLimit, setSearchLimit] = useState(20)
111
+ const [searchResults, setSearchResults] = useState<MemoryEntry[] | null>(null)
112
+ const [searching, setSearching] = useState(false)
113
+
114
+ const [showStore, setShowStore] = useState(false)
115
+ const [storeKey, setStoreKey] = useState('')
116
+ const [storeValue, setStoreValue] = useState('')
117
+ const [storeNs, setStoreNs] = useState('')
118
+ const [storeTags, setStoreTags] = useState('')
119
+ const [storeTtl, setStoreTtl] = useState('')
120
+ const [storing, setStoring] = useState(false)
121
+
122
+ const [filterNs, setFilterNs] = useState<string | null>(null)
123
+ const [expandedKey, setExpandedKey] = useState<string | null>(null)
124
+ const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
125
+
126
+ const fetchAll = useCallback(async () => {
127
+ setLoading(true)
128
+ try {
129
+ const [statsRes, entriesRes] = await Promise.all([
130
+ api.memory.stats(),
131
+ api.memory.list(),
132
+ ])
133
+ setMemoryStats(statsRes as MemoryStats)
134
+ const eData = entriesRes as { entries?: MemoryEntry[] } | MemoryEntry[]
135
+ setMemoryEntries(Array.isArray(eData) ? eData : (eData.entries ?? []))
136
+ } catch (e) {
137
+ console.error('Failed to fetch memory data', e)
138
+ } finally {
139
+ setLoading(false)
140
+ }
141
+ }, [setMemoryStats, setMemoryEntries])
142
+
143
+ useEffect(() => { fetchAll() }, [fetchAll])
144
+
145
+ const handleSearch = async () => {
146
+ if (!searchQuery.trim()) return
147
+ setSearching(true)
148
+ try {
149
+ const res = await api.memory.search(searchQuery, searchNs || undefined, searchLimit) as MemoryEntry[]
150
+ setSearchResults(Array.isArray(res) ? res : [])
151
+ } catch (e) {
152
+ console.error('Search failed', e)
153
+ } finally {
154
+ setSearching(false)
155
+ }
156
+ }
157
+
158
+ const handleStore = async () => {
159
+ if (!storeKey.trim() || !storeValue.trim()) return
160
+ setStoring(true)
161
+ try {
162
+ await api.memory.store({
163
+ key: storeKey,
164
+ value: storeValue,
165
+ namespace: storeNs || undefined,
166
+ tags: storeTags ? storeTags.split(',').map((t) => t.trim()).filter(Boolean) : undefined,
167
+ ttl: storeTtl ? Number(storeTtl) : undefined,
168
+ })
169
+ setStoreKey(''); setStoreValue(''); setStoreNs(''); setStoreTags(''); setStoreTtl('')
170
+ setShowStore(false)
171
+ fetchAll()
172
+ } catch (e) {
173
+ console.error('Store failed', e)
174
+ } finally {
175
+ setStoring(false)
176
+ }
177
+ }
178
+
179
+ const handleDelete = async (key: string) => {
180
+ try {
181
+ await api.memory.delete(key, filterNs || undefined)
182
+ setDeleteConfirm(null)
183
+ fetchAll()
184
+ } catch (e) {
185
+ console.error('Delete failed', e)
186
+ }
187
+ }
188
+
189
+ const namespaces = memoryStats?.namespaces ?? []
190
+ const filtered = filterNs
191
+ ? memoryEntries.filter((e) => e.namespace === filterNs)
192
+ : memoryEntries
193
+
194
+ return (
195
+ <div style={s.page}>
196
+ {/* Stats Banner */}
197
+ <div style={s.statsRow}>
198
+ <div style={s.statCard}>
199
+ <div style={s.statLabel}>Total Entries</div>
200
+ <div style={s.statValue}>{memoryStats?.totalEntries ?? '--'}</div>
201
+ </div>
202
+ <div style={s.statCard}>
203
+ <div style={s.statLabel}>Namespaces</div>
204
+ <div style={s.statValue}>{namespaces.length}</div>
205
+ </div>
206
+ <div style={s.statCard}>
207
+ <div style={s.statLabel}>Storage Size</div>
208
+ <div style={s.statValue}>{memoryStats?.storageSize ?? '--'}</div>
209
+ </div>
210
+ <div style={s.statCard}>
211
+ <div style={s.statLabel}>Indexed Vectors</div>
212
+ <div style={s.statValue}>{memoryStats?.indexedVectors ?? '--'}</div>
213
+ <div style={s.hnswBadge(memoryStats?.hnswEnabled ?? false)}>
214
+ <span style={{
215
+ width: 6, height: 6, borderRadius: '50%',
216
+ background: memoryStats?.hnswEnabled ? 'var(--accent-green)' : 'var(--accent-red)',
217
+ }} />
218
+ HNSW {memoryStats?.hnswEnabled ? 'Active' : 'Inactive'}
219
+ </div>
220
+ </div>
221
+ </div>
222
+
223
+ {/* Search */}
224
+ <Card title="Search Memory" actions={
225
+ <Button variant="ghost" size="sm" onClick={fetchAll} loading={loading}>
226
+ <RefreshCw size={14} /> Refresh
227
+ </Button>
228
+ }>
229
+ <div style={s.searchRow}>
230
+ <div style={{ ...s.field, flex: 2 }}>
231
+ <span style={s.label}>Query</span>
232
+ <input
233
+ style={{ ...s.input, width: '100%' }}
234
+ placeholder="Search memory entries..."
235
+ value={searchQuery}
236
+ onChange={(e) => setSearchQuery(e.target.value)}
237
+ onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
238
+ />
239
+ </div>
240
+ <div style={s.field}>
241
+ <span style={s.label}>Namespace</span>
242
+ <select
243
+ style={{ ...s.input, minWidth: 140 }}
244
+ value={searchNs}
245
+ onChange={(e) => setSearchNs(e.target.value)}
246
+ >
247
+ <option value="">All</option>
248
+ {namespaces.map((ns) => <option key={ns} value={ns}>{ns}</option>)}
249
+ </select>
250
+ </div>
251
+ <div style={s.field}>
252
+ <span style={s.label}>Limit</span>
253
+ <input
254
+ style={{ ...s.input, width: 70 }}
255
+ type="number"
256
+ min={1}
257
+ max={100}
258
+ value={searchLimit}
259
+ onChange={(e) => setSearchLimit(Number(e.target.value))}
260
+ />
261
+ </div>
262
+ <Button onClick={handleSearch} loading={searching} size="md">
263
+ <Search size={14} /> Search
264
+ </Button>
265
+ </div>
266
+
267
+ {searchResults !== null && (
268
+ <div style={{ marginTop: 14 }}>
269
+ <div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 8 }}>
270
+ {searchResults.length} result{searchResults.length !== 1 ? 's' : ''}
271
+ </div>
272
+ {searchResults.length === 0 ? (
273
+ <div style={s.empty}>No results found</div>
274
+ ) : (
275
+ searchResults.map((entry) => (
276
+ <div
277
+ key={entry.key}
278
+ style={s.resultItem}
279
+ onClick={() => setExpandedKey(expandedKey === entry.key ? null : entry.key)}
280
+ onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--bg-hover)' }}
281
+ onMouseLeave={(e) => { e.currentTarget.style.background = '' }}
282
+ >
283
+ <div style={s.resultKey}>{entry.key}</div>
284
+ <div style={s.resultVal}>{truncate(entry.value)}</div>
285
+ {entry.tags?.length > 0 && (
286
+ <div style={{ marginTop: 4 }}>
287
+ {entry.tags.map((t) => <span key={t} style={s.tag}>{t}</span>)}
288
+ </div>
289
+ )}
290
+ {expandedKey === entry.key && (
291
+ <div style={s.expandedValue}>{entry.value}</div>
292
+ )}
293
+ </div>
294
+ ))
295
+ )}
296
+ </div>
297
+ )}
298
+ </Card>
299
+
300
+ {/* Store New Entry */}
301
+ <Card title="Store New Entry" actions={
302
+ <Button variant="ghost" size="sm" onClick={() => setShowStore(!showStore)}>
303
+ <Plus size={14} /> {showStore ? 'Collapse' : 'Expand'}
304
+ </Button>
305
+ }>
306
+ {showStore && (
307
+ <div style={s.storeForm}>
308
+ <div style={s.field}>
309
+ <span style={s.label}>Key *</span>
310
+ <input style={s.input} placeholder="entry-key" value={storeKey}
311
+ onChange={(e) => setStoreKey(e.target.value)} />
312
+ </div>
313
+ <div style={s.field}>
314
+ <span style={s.label}>Namespace</span>
315
+ <input style={s.input} placeholder="default" value={storeNs}
316
+ onChange={(e) => setStoreNs(e.target.value)} />
317
+ </div>
318
+ <div style={{ ...s.field, ...s.fullSpan }}>
319
+ <span style={s.label}>Value *</span>
320
+ <textarea style={s.textarea as CSSProperties} placeholder="Entry value..."
321
+ value={storeValue} onChange={(e) => setStoreValue(e.target.value)} />
322
+ </div>
323
+ <div style={s.field}>
324
+ <span style={s.label}>Tags (comma separated)</span>
325
+ <input style={s.input} placeholder="tag1, tag2" value={storeTags}
326
+ onChange={(e) => setStoreTags(e.target.value)} />
327
+ </div>
328
+ <div style={s.field}>
329
+ <span style={s.label}>TTL (seconds, optional)</span>
330
+ <input style={s.input} type="number" placeholder="3600" value={storeTtl}
331
+ onChange={(e) => setStoreTtl(e.target.value)} />
332
+ </div>
333
+ <div style={{ ...s.fullSpan, display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
334
+ <Button variant="secondary" size="sm" onClick={() => setShowStore(false)}>Cancel</Button>
335
+ <Button size="sm" onClick={handleStore} loading={storing}
336
+ disabled={!storeKey.trim() || !storeValue.trim()}>
337
+ <Database size={14} /> Store Entry
338
+ </Button>
339
+ </div>
340
+ </div>
341
+ )}
342
+ {!showStore && (
343
+ <div style={{ color: 'var(--text-muted)', fontSize: 13 }}>
344
+ Click "Expand" to add a new memory entry.
345
+ </div>
346
+ )}
347
+ </Card>
348
+
349
+ {/* Memory Entries */}
350
+ <Card title={`Memory Entries (${filtered.length})`} actions={
351
+ <div style={s.row}>
352
+ <StatusBadge status={loading ? 'running' : 'idle'} size="sm" />
353
+ </div>
354
+ }>
355
+ {/* Namespace filter tabs */}
356
+ <div style={s.nsTabs}>
357
+ <span
358
+ style={s.nsTab(filterNs === null)}
359
+ onClick={() => setFilterNs(null)}
360
+ >All ({memoryEntries.length})</span>
361
+ {namespaces.map((ns) => {
362
+ const count = memoryEntries.filter((e) => e.namespace === ns).length
363
+ return (
364
+ <span key={ns} style={s.nsTab(filterNs === ns)}
365
+ onClick={() => setFilterNs(filterNs === ns ? null : ns)}>
366
+ {ns} ({count})
367
+ </span>
368
+ )
369
+ })}
370
+ </div>
371
+
372
+ {filtered.length === 0 ? (
373
+ <div style={s.empty}>No memory entries{filterNs ? ` in "${filterNs}"` : ''}</div>
374
+ ) : (
375
+ <div style={{ overflowX: 'auto' }}>
376
+ <table style={s.table}>
377
+ <thead>
378
+ <tr>
379
+ <th style={s.th}>Key</th>
380
+ <th style={s.th}>Value</th>
381
+ <th style={s.th}>Namespace</th>
382
+ <th style={s.th}>Tags</th>
383
+ <th style={s.th}>Created</th>
384
+ <th style={s.th}>Actions</th>
385
+ </tr>
386
+ </thead>
387
+ <tbody>
388
+ {filtered.map((entry) => (
389
+ <tr key={entry.key}>
390
+ <td style={{ ...s.td, fontWeight: 600, color: 'var(--accent-cyan)' }}>
391
+ {entry.key}
392
+ </td>
393
+ <td style={{ ...s.td, maxWidth: 260, cursor: 'pointer' }}
394
+ onClick={() => setExpandedKey(expandedKey === entry.key ? null : entry.key)}>
395
+ {expandedKey === entry.key ? (
396
+ <div style={s.expandedValue}>{entry.value}</div>
397
+ ) : truncate(entry.value)}
398
+ </td>
399
+ <td style={s.td}>{entry.namespace}</td>
400
+ <td style={s.td}>
401
+ {entry.tags?.map((t) => <span key={t} style={s.tag}>{t}</span>)}
402
+ </td>
403
+ <td style={{ ...s.td, whiteSpace: 'nowrap', fontSize: 12 }}>
404
+ {new Date(entry.createdAt).toLocaleString()}
405
+ </td>
406
+ <td style={s.td}>
407
+ <div style={s.actions}>
408
+ <Button variant="danger" size="sm"
409
+ onClick={() => setDeleteConfirm(entry.key)}>
410
+ <Trash2 size={12} />
411
+ </Button>
412
+ </div>
413
+ </td>
414
+ </tr>
415
+ ))}
416
+ </tbody>
417
+ </table>
418
+ </div>
419
+ )}
420
+ </Card>
421
+
422
+ {/* Delete Confirmation Dialog */}
423
+ {deleteConfirm && (
424
+ <div style={s.overlay} onClick={() => setDeleteConfirm(null)}>
425
+ <div style={s.dialog} onClick={(e) => e.stopPropagation()}>
426
+ <div style={s.dialogTitle}>Confirm Delete</div>
427
+ <p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 20 }}>
428
+ Are you sure you want to delete memory entry "<strong>{deleteConfirm}</strong>"?
429
+ This action cannot be undone.
430
+ </p>
431
+ <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
432
+ <Button variant="secondary" size="sm" onClick={() => setDeleteConfirm(null)}>
433
+ Cancel
434
+ </Button>
435
+ <Button variant="danger" size="sm" onClick={() => handleDelete(deleteConfirm)}>
436
+ <Trash2 size={12} /> Delete
437
+ </Button>
438
+ </div>
439
+ </div>
440
+ </div>
441
+ )}
442
+ </div>
443
+ )
444
+ }