vibepulse 0.1.0 → 0.1.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.
Files changed (68) hide show
  1. package/README.md +7 -13
  2. package/bin/vibepulse.js +1 -0
  3. package/dist/index.js +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/docs/session-status-detection.md +258 -0
  6. package/next.config.ts +11 -0
  7. package/package.json +17 -11
  8. package/postcss.config.mjs +7 -0
  9. package/public/file.svg +1 -0
  10. package/public/globe.svg +1 -0
  11. package/public/next.svg +1 -0
  12. package/public/readme-cover.png +0 -0
  13. package/public/vercel.svg +1 -0
  14. package/public/window.svg +1 -0
  15. package/src/app/api/opencode-config/route.ts +304 -0
  16. package/src/app/api/opencode-config/status/route.ts +31 -0
  17. package/src/app/api/opencode-events/route.ts +86 -0
  18. package/src/app/api/opencode-models/route.test.ts +135 -0
  19. package/src/app/api/opencode-models/route.ts +58 -0
  20. package/src/app/api/profiles/[id]/apply/route.ts +49 -0
  21. package/src/app/api/profiles/[id]/route.ts +160 -0
  22. package/src/app/api/profiles/route.ts +107 -0
  23. package/src/app/api/sessions/[id]/archive/route.ts +35 -0
  24. package/src/app/api/sessions/[id]/delete/route.ts +26 -0
  25. package/src/app/api/sessions/[id]/route.ts +45 -0
  26. package/src/app/api/sessions/route.ts +596 -0
  27. package/src/app/favicon.ico +0 -0
  28. package/src/app/globals.css +66 -0
  29. package/src/app/layout.tsx +37 -0
  30. package/src/app/page.tsx +239 -0
  31. package/src/components/ErrorBoundary.tsx +72 -0
  32. package/src/components/KanbanBoard.tsx +442 -0
  33. package/src/components/LoadingState.tsx +37 -0
  34. package/src/components/ProjectCard.tsx +382 -0
  35. package/src/components/QueryProvider.tsx +25 -0
  36. package/src/components/SessionCard.tsx +291 -0
  37. package/src/components/SessionList.tsx +60 -0
  38. package/src/components/opencode-config/AgentConfigForm.test.tsx +66 -0
  39. package/src/components/opencode-config/AgentConfigForm.tsx +445 -0
  40. package/src/components/opencode-config/AgentModelSelector.tsx +284 -0
  41. package/src/components/opencode-config/AgentsConfigPanel.tsx +162 -0
  42. package/src/components/opencode-config/ConfigButton.tsx +43 -0
  43. package/src/components/opencode-config/ConfigPanel.tsx +91 -0
  44. package/src/components/opencode-config/FullscreenConfigPanel.tsx +360 -0
  45. package/src/components/opencode-config/categories/CategoriesList.tsx +328 -0
  46. package/src/components/opencode-config/categories/CategoriesManager.test.tsx +97 -0
  47. package/src/components/opencode-config/categories/CategoriesManager.tsx +174 -0
  48. package/src/components/opencode-config/categories/CategoryConfigForm.tsx +384 -0
  49. package/src/components/opencode-config/profiles/ProfileCard.tsx +140 -0
  50. package/src/components/opencode-config/profiles/ProfileEditor.tsx +446 -0
  51. package/src/components/opencode-config/profiles/ProfileList.tsx +398 -0
  52. package/src/components/opencode-config/profiles/ProfileManager.test.tsx +122 -0
  53. package/src/components/opencode-config/profiles/ProfileManager.tsx +293 -0
  54. package/src/components/ui/Tabs.tsx +59 -0
  55. package/src/hooks/useOpencodeSync.ts +378 -0
  56. package/src/index.ts +2 -0
  57. package/src/lib/notificationSound.ts +266 -0
  58. package/src/lib/opencodeConfig.test.ts +81 -0
  59. package/src/lib/opencodeConfig.ts +48 -0
  60. package/src/lib/opencodeDiscovery.ts +154 -0
  61. package/src/lib/profiles/storage.ts +264 -0
  62. package/src/lib/transform.ts +84 -0
  63. package/src/test/setup.ts +8 -0
  64. package/src/types/index.ts +89 -0
  65. package/src/types/opencodeConfig.ts +133 -0
  66. package/src/types/testing-library-vitest.d.ts +17 -0
  67. package/tsconfig.json +34 -0
  68. package/tsconfig.lib.json +17 -0
@@ -0,0 +1,382 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { useQueryClient } from '@tanstack/react-query';
5
+ import { KanbanCard } from '@/types';
6
+
7
+ interface ProjectCardProps {
8
+ projectName: string;
9
+ branch?: string;
10
+ cards: KanbanCard[];
11
+ readOnly?: boolean;
12
+ }
13
+
14
+ function formatRelativeTime(timestamp: number): string {
15
+ const diffMs = Date.now() - timestamp;
16
+ const diffMins = Math.floor(diffMs / (1000 * 60));
17
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
18
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
19
+
20
+ if (diffMins < 1) return '<1m';
21
+ if (diffHours < 1) return `${diffMins}m`;
22
+ if (diffDays < 1) return `${diffHours}h`;
23
+ return `${diffDays}d`;
24
+ }
25
+
26
+ function StatusDot({ status, waitingForUser }: { status: string; waitingForUser: boolean }) {
27
+ if (waitingForUser) {
28
+ return (
29
+ <span className="relative flex h-2 w-2 flex-shrink-0" title="Waiting">
30
+ <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
31
+ <span className="relative inline-flex rounded-full h-2 w-2 bg-amber-500"></span>
32
+ </span>
33
+ );
34
+ }
35
+ switch (status) {
36
+ case 'busy':
37
+ return (
38
+ <span className="relative flex h-2 w-2 flex-shrink-0" title="Running">
39
+ <span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
40
+ <span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
41
+ </span>
42
+ );
43
+ case 'retry':
44
+ return (
45
+ <span className="relative flex h-2 w-2 flex-shrink-0" title="Retrying">
46
+ <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
47
+ <span className="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
48
+ </span>
49
+ );
50
+ default:
51
+ return <span className="inline-flex rounded-full h-2 w-2 bg-gray-400 flex-shrink-0" title="Idle"></span>;
52
+ }
53
+ }
54
+
55
+ function HeaderActionMenu({ cards, readOnly = false }: { cards: KanbanCard[]; readOnly?: boolean }) {
56
+ const queryClient = useQueryClient();
57
+ const [open, setOpen] = useState(false);
58
+ const menuRef = useRef<HTMLDivElement>(null);
59
+
60
+ useEffect(() => {
61
+ if (!open) return;
62
+ const handler = (e: MouseEvent) => {
63
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
64
+ setOpen(false);
65
+ }
66
+ };
67
+ document.addEventListener('mousedown', handler);
68
+ return () => document.removeEventListener('mousedown', handler);
69
+ }, [open]);
70
+
71
+ const hasUnarchived = cards.some(c => c.status !== 'done');
72
+
73
+ const handleArchiveAll = async (e: React.MouseEvent) => {
74
+ e.stopPropagation();
75
+ const unarchivedCards = cards.filter(c => c.status !== 'done');
76
+ await Promise.all(unarchivedCards.map(card =>
77
+ fetch(`/api/sessions/${card.id}/archive`, { method: 'POST' })
78
+ ));
79
+ setOpen(false);
80
+ queryClient.invalidateQueries({ queryKey: ['sessions'] });
81
+ };
82
+
83
+ const handleDeleteAll = async (e: React.MouseEvent) => {
84
+ e.stopPropagation();
85
+ if (!confirm(`Delete ${cards.length} session(s)? This cannot be undone.`)) return;
86
+ await Promise.all(cards.map(card =>
87
+ fetch(`/api/sessions/${card.id}/delete`, { method: 'POST' })
88
+ ));
89
+ setOpen(false);
90
+ queryClient.invalidateQueries({ queryKey: ['sessions'] });
91
+ };
92
+
93
+ if (readOnly) return null;
94
+
95
+ return (
96
+ <div className="relative" ref={menuRef}>
97
+ <button
98
+ type="button"
99
+ className="w-5 h-5 flex items-center justify-center rounded text-gray-400 hover:text-gray-600 hover:bg-gray-200 dark:text-gray-500 dark:hover:text-gray-300 dark:hover:bg-zinc-600 transition-colors"
100
+ onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
101
+ title="Batch actions"
102
+ >
103
+ <svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
104
+ <path d="M5 10a2 2 0 110 4 2 2 0 010-4zm7 0a2 2 0 110 4 2 2 0 010-4zm7 0a2 2 0 110 4 2 2 0 010-4z" />
105
+ </svg>
106
+ </button>
107
+ {open && (
108
+ <div className="absolute right-0 top-6 w-32 rounded-md border border-gray-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-900 z-20">
109
+ {hasUnarchived && (
110
+ <button
111
+ type="button"
112
+ className="w-full text-left px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-zinc-800"
113
+ onClick={handleArchiveAll}
114
+ >
115
+ Archive all
116
+ </button>
117
+ )}
118
+ <button
119
+ type="button"
120
+ className="w-full text-left px-3 py-1.5 text-xs text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
121
+ onClick={handleDeleteAll}
122
+ >
123
+ Delete all
124
+ </button>
125
+ </div>
126
+ )}
127
+ </div>
128
+ );
129
+ }
130
+
131
+ // Hover-reveal action menu for each session row
132
+ function RowActionMenu({ cardId, archived }: { cardId: string; archived: boolean }) {
133
+ const queryClient = useQueryClient();
134
+ const [open, setOpen] = useState(false);
135
+ const menuRef = useRef<HTMLDivElement>(null);
136
+
137
+ useEffect(() => {
138
+ if (!open) return;
139
+ const handler = (e: MouseEvent) => {
140
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
141
+ setOpen(false);
142
+ }
143
+ };
144
+ document.addEventListener('mousedown', handler);
145
+ return () => document.removeEventListener('mousedown', handler);
146
+ }, [open]);
147
+
148
+ const handleArchive = async (e: React.MouseEvent) => {
149
+ e.stopPropagation();
150
+ try {
151
+ await fetch(`/api/sessions/${cardId}/archive`, { method: 'POST' });
152
+ } finally {
153
+ setOpen(false);
154
+ queryClient.invalidateQueries({ queryKey: ['sessions'] });
155
+ }
156
+ };
157
+
158
+ const handleDelete = async (e: React.MouseEvent) => {
159
+ e.stopPropagation();
160
+ try {
161
+ await fetch(`/api/sessions/${cardId}/delete`, { method: 'POST' });
162
+ } finally {
163
+ setOpen(false);
164
+ queryClient.invalidateQueries({ queryKey: ['sessions'] });
165
+ }
166
+ };
167
+
168
+ return (
169
+ <div className="relative flex-shrink-0" ref={menuRef}>
170
+ <button
171
+ type="button"
172
+ className="w-5 h-5 flex items-center justify-center rounded text-gray-400 hover:text-gray-600 hover:bg-gray-200 dark:text-gray-500 dark:hover:text-gray-300 dark:hover:bg-zinc-600 transition-colors"
173
+ onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
174
+ title="Actions"
175
+ >
176
+ <svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
177
+ <path d="M5 10a2 2 0 110 4 2 2 0 010-4zm7 0a2 2 0 110 4 2 2 0 010-4zm7 0a2 2 0 110 4 2 2 0 010-4z" />
178
+ </svg>
179
+ </button>
180
+ {open && (
181
+ <div className="absolute right-0 top-6 w-28 rounded-md border border-gray-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-900 z-20">
182
+ {!archived ? (
183
+ <button
184
+ type="button"
185
+ className="w-full text-left px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-zinc-800"
186
+ onClick={handleArchive}
187
+ >
188
+ Archive
189
+ </button>
190
+ ) : null}
191
+ <button
192
+ type="button"
193
+ className="w-full text-left px-3 py-1.5 text-xs text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
194
+ onClick={handleDelete}
195
+ >
196
+ Delete
197
+ </button>
198
+ </div>
199
+ )}
200
+ </div>
201
+ );
202
+ }
203
+
204
+ // Session row with expandable subagent children
205
+ function SessionRow({ card, isLast, readOnly = false }: { card: KanbanCard; isLast: boolean; readOnly?: boolean }) {
206
+ const [expanded, setExpanded] = useState(true);
207
+ const visibleChildren = (card.children || []).filter(
208
+ (child) => child.realTimeStatus !== 'idle' || child.waitingForUser
209
+ );
210
+ const hasChildren = visibleChildren.length > 0;
211
+
212
+ return (
213
+ <div className={!isLast ? 'border-b border-gray-50 dark:border-zinc-700/30' : ''}>
214
+ <div
215
+ className="group/row flex items-center gap-2.5 px-3 py-2 hover:bg-gray-50 dark:hover:bg-zinc-700/30 transition-colors"
216
+ title={`${card.title || 'Untitled Session'}\nActive ${formatRelativeTime(card.updatedAt)} ago · Started ${formatRelativeTime(card.createdAt)} ago`}
217
+ >
218
+ {/* Expand toggle or spacer */}
219
+ {hasChildren && (
220
+ <button
221
+ type="button"
222
+ onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
223
+ className="w-3 h-3 flex items-center justify-center text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 flex-shrink-0 transition-transform"
224
+ title={expanded ? 'Collapse subagents' : 'Expand subagents'}
225
+ >
226
+ <svg
227
+ className={`w-2.5 h-2.5 transition-transform duration-150 ${expanded ? 'rotate-90' : ''}`}
228
+ viewBox="0 0 6 10" fill="currentColor"
229
+ aria-hidden="true"
230
+ >
231
+ <path d="M1 1l4 4-4 4" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
232
+ </svg>
233
+ </button>
234
+ )}
235
+ <StatusDot status={card.opencodeStatus} waitingForUser={card.waitingForUser} />
236
+ <span className="text-sm text-gray-700 dark:text-gray-300 truncate flex-1 min-w-0">
237
+ {card.title || 'Untitled Session'}
238
+ </span>
239
+ {/* Child count badge */}
240
+ {hasChildren && !expanded && (
241
+ <span className="text-[9px] font-medium text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-zinc-700 px-1 py-0.5 rounded flex-shrink-0">
242
+ {visibleChildren.length} sub
243
+ </span>
244
+ )}
245
+ {/* Time: visible by default, hidden on hover */}
246
+ <span className="text-[10px] text-gray-400 dark:text-gray-500 flex-shrink-0 tabular-nums group-hover/row:hidden">
247
+ {formatRelativeTime(card.updatedAt)}
248
+ </span>
249
+ {/* Action menu: hidden by default, visible on hover */}
250
+ {!readOnly ? (
251
+ <div className="hidden group-hover/row:flex flex-shrink-0">
252
+ <RowActionMenu cardId={card.id} archived={card.status === 'done'} />
253
+ </div>
254
+ ) : null}
255
+ </div>
256
+ {/* Subagent children */}
257
+ {hasChildren && expanded && (
258
+ <div className="bg-gray-50/50 dark:bg-zinc-800/30">
259
+ {visibleChildren.map((child, i) => (
260
+ <div
261
+ key={child.id}
262
+ className="flex items-center gap-2 pl-8 pr-3 py-1.5 hover:bg-gray-100/50 dark:hover:bg-zinc-700/20 transition-colors"
263
+ title={child.title || 'Subagent'}
264
+ >
265
+ {/* Tree connector */}
266
+ <span className="text-gray-300 dark:text-zinc-600 text-xs flex-shrink-0 font-mono leading-none">
267
+ {i === visibleChildren.length - 1 ? '└' : '├'}
268
+ </span>
269
+ <StatusDot status={child.realTimeStatus} waitingForUser={child.waitingForUser} />
270
+ <span className="text-xs text-gray-500 dark:text-gray-400 truncate flex-1 min-w-0">
271
+ {child.title || 'Subagent'}
272
+ </span>
273
+ </div>
274
+ ))}
275
+ </div>
276
+ )}
277
+ </div>
278
+ );
279
+ }
280
+
281
+ export function ProjectCard({ projectName, branch, cards, readOnly = false }: ProjectCardProps) {
282
+ const [openTool, setOpenTool] = useState(() => {
283
+ if (typeof window === 'undefined') return 'vscode';
284
+ return window.localStorage.getItem('vibepulse:open-tool') || 'vscode';
285
+ });
286
+ const [remoteSshHost] = useState(() => {
287
+ if (typeof window === 'undefined') return '';
288
+ const storedHost = window.localStorage.getItem('vibepulse:ssh-host');
289
+ if (storedHost) return storedHost;
290
+ const hostname = window.location.hostname;
291
+ if (hostname && hostname !== 'localhost' && hostname !== '127.0.0.1') {
292
+ return hostname;
293
+ }
294
+ return '';
295
+ });
296
+
297
+ const buildVsCodeUri = (directory: string) => {
298
+ const encodedPath = encodeURI(directory.replace(/\\/g, '/'));
299
+ if (remoteSshHost) {
300
+ return `vscode://vscode-remote/ssh-remote+${remoteSshHost}${encodedPath.startsWith('/') ? '' : '/'}${encodedPath}`;
301
+ }
302
+ return `vscode://file${encodedPath.startsWith('/') ? encodedPath : `/${encodedPath}`}`;
303
+ };
304
+
305
+ const handleOpenProject = () => {
306
+ const directory = cards[0]?.directory;
307
+ if (!directory) return;
308
+ const target = openTool === 'antigravity'
309
+ ? `antigravity://file${directory}`
310
+ : buildVsCodeUri(directory);
311
+ window.location.href = target;
312
+ };
313
+
314
+ return (
315
+ <article className="w-full bg-white dark:bg-zinc-800 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-700 hover:shadow-lg hover:border-gray-300 dark:hover:border-zinc-600 transition-all duration-200 overflow-visible">
316
+ {/* Header */}
317
+ <div className="group/header flex items-center gap-2 px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-zinc-700/30 transition-colors">
318
+ <svg className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
319
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
320
+ </svg>
321
+ <span className="text-sm font-semibold text-gray-800 dark:text-gray-200 truncate flex-1">
322
+ {projectName}
323
+ </span>
324
+ {branch && (
325
+ <span className="text-[10px] bg-gray-100 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 px-1.5 py-0.5 rounded flex-shrink-0">
326
+ {branch}
327
+ </span>
328
+ )}
329
+ {cards.length > 1 && (
330
+ <span className="text-[10px] text-gray-400 dark:text-gray-500 font-medium bg-gray-100 dark:bg-zinc-700 px-1.5 py-0.5 rounded-full flex-shrink-0">
331
+ {cards.length}
332
+ </span>
333
+ )}
334
+ {!readOnly && (
335
+ <div className="hidden group-hover/header:flex flex-shrink-0">
336
+ <HeaderActionMenu cards={cards} readOnly={readOnly} />
337
+ </div>
338
+ )}
339
+ </div>
340
+
341
+ {/* Session rows */}
342
+ <div className="border-t border-gray-100 dark:border-zinc-700/50">
343
+ {cards.map((card, index) => (
344
+ <SessionRow
345
+ key={card.id}
346
+ card={card}
347
+ isLast={index === cards.length - 1}
348
+ readOnly={readOnly}
349
+ />
350
+ ))}
351
+ </div>
352
+
353
+ {/* Footer */}
354
+ <div className="flex items-center justify-end gap-1.5 px-3 py-1.5 border-t border-gray-100 dark:border-zinc-700/50 bg-gray-50/50 dark:bg-zinc-800/50">
355
+ <select
356
+ className="text-[10px] rounded border border-gray-200 bg-white px-1 py-0.5 text-gray-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-gray-400 focus:outline-none"
357
+ value={openTool}
358
+ onClick={(e) => e.stopPropagation()}
359
+ onChange={(e) => {
360
+ setOpenTool(e.target.value);
361
+ window.localStorage.setItem('vibepulse:open-tool', e.target.value);
362
+ }}
363
+ title="Select open tool"
364
+ >
365
+ <option value="vscode">VSCode</option>
366
+ <option value="antigravity">Antigravity</option>
367
+ </select>
368
+ <button
369
+ type="button"
370
+ onClick={handleOpenProject}
371
+ className="flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-medium text-gray-500 hover:text-blue-600 hover:bg-blue-50 dark:text-gray-400 dark:hover:text-blue-400 dark:hover:bg-blue-900/20 transition-colors"
372
+ title="Open project"
373
+ >
374
+ <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
375
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
376
+ </svg>
377
+ Open
378
+ </button>
379
+ </div>
380
+ </article>
381
+ );
382
+ }
@@ -0,0 +1,25 @@
1
+ 'use client';
2
+
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4
+ import { ReactNode } from 'react';
5
+
6
+ const queryClient = new QueryClient({
7
+ defaultOptions: {
8
+ queries: {
9
+ refetchOnWindowFocus: false,
10
+ staleTime: 0,
11
+ },
12
+ },
13
+ });
14
+
15
+ interface QueryProviderProps {
16
+ children: ReactNode;
17
+ }
18
+
19
+ export function QueryProvider({ children }: QueryProviderProps) {
20
+ return (
21
+ <QueryClientProvider client={queryClient}>
22
+ {children}
23
+ </QueryClientProvider>
24
+ );
25
+ }