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,291 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { useQueryClient } from '@tanstack/react-query';
5
+
6
+ import { KanbanCard } from '@/types';
7
+
8
+ interface SessionCardProps {
9
+ card: KanbanCard;
10
+ }
11
+
12
+ function formatRelativeTime(timestamp: number): string {
13
+ const diffMs = Date.now() - timestamp;
14
+ const diffMins = Math.floor(diffMs / (1000 * 60));
15
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
16
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
17
+
18
+ if (diffMins < 1) return '<1m ago';
19
+ if (diffHours < 1) return `${diffMins}m ago`;
20
+ if (diffDays < 1) return `${diffHours}h ago`;
21
+ return `${diffDays}d ago`;
22
+ }
23
+
24
+ // Status indicator component
25
+ function StatusIndicator({ status, waitingForUser }: { status: string; waitingForUser: boolean }) {
26
+ if (waitingForUser) {
27
+ return (
28
+ <div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
29
+ <span className="relative flex h-2.5 w-2.5">
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.5 w-2.5 bg-amber-500"></span>
32
+ </span>
33
+ <span className="text-xs font-medium">Waiting</span>
34
+ </div>
35
+ );
36
+ }
37
+ switch (status) {
38
+ case 'busy':
39
+ return (
40
+ <div className="flex items-center gap-1.5 text-emerald-600 dark:text-emerald-400">
41
+ <span className="relative flex h-2.5 w-2.5">
42
+ <span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
43
+ <span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
44
+ </span>
45
+ <span className="text-xs font-medium">Running</span>
46
+ </div>
47
+ );
48
+ case 'retry':
49
+ return (
50
+ <div className="flex items-center gap-1.5 text-red-600 dark:text-red-400">
51
+ <span className="relative flex h-2.5 w-2.5">
52
+ <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
53
+ <span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-red-500"></span>
54
+ </span>
55
+ <span className="text-xs font-medium">Retrying</span>
56
+ </div>
57
+ );
58
+ case 'idle':
59
+ default:
60
+ return (
61
+ <div className="flex items-center gap-1.5 text-gray-500 dark:text-gray-400">
62
+ <span className="inline-flex rounded-full h-2.5 w-2.5 bg-gray-400"></span>
63
+ <span className="text-xs font-medium">Idle</span>
64
+ </div>
65
+ );
66
+ }
67
+ }
68
+
69
+ export function SessionCard({ card }: SessionCardProps) {
70
+ const queryClient = useQueryClient();
71
+ const [openTool, setOpenTool] = useState('vscode');
72
+ const [remoteSshHost, setRemoteSshHost] = useState('');
73
+ const [actionOpen, setActionOpen] = useState(false);
74
+ const actionMenuRef = useRef<HTMLDivElement>(null);
75
+ const lastActiveLabel = `Last active: ${formatRelativeTime(card.updatedAt)}`;
76
+ const startedLabel = `Started: ${formatRelativeTime(card.createdAt)}`;
77
+ const todoProgress = card.todosTotal > 0
78
+ ? Math.round((card.todosCompleted / card.todosTotal) * 100)
79
+ : 0;
80
+
81
+ useEffect(() => {
82
+ const storedTool = window.localStorage.getItem('vibepulse:open-tool');
83
+ if (storedTool) {
84
+ setOpenTool(storedTool);
85
+ }
86
+ const storedHost = window.localStorage.getItem('vibepulse:ssh-host');
87
+ if (storedHost) {
88
+ setRemoteSshHost(storedHost);
89
+ } else {
90
+ const hostname = window.location.hostname;
91
+ if (hostname && hostname !== 'localhost' && hostname !== '127.0.0.1') {
92
+ setRemoteSshHost(hostname);
93
+ window.localStorage.setItem('vibepulse:ssh-host', hostname);
94
+ }
95
+ }
96
+ }, []);
97
+
98
+ // Close dropdown on outside click
99
+ useEffect(() => {
100
+ if (!actionOpen) return;
101
+ const handleClickOutside = (e: MouseEvent) => {
102
+ if (actionMenuRef.current && !actionMenuRef.current.contains(e.target as Node)) {
103
+ setActionOpen(false);
104
+ }
105
+ };
106
+ document.addEventListener('mousedown', handleClickOutside);
107
+ return () => document.removeEventListener('mousedown', handleClickOutside);
108
+ }, [actionOpen]);
109
+
110
+ const buildVsCodeUri = (directory: string) => {
111
+ const encodedPath = encodeURI(directory.replace(/\\/g, '/'));
112
+ if (remoteSshHost) {
113
+ return `vscode://vscode-remote/ssh-remote+${remoteSshHost}${encodedPath.startsWith('/') ? '' : '/'}${encodedPath}`;
114
+ }
115
+ return `vscode://file${encodedPath.startsWith('/') ? encodedPath : `/${encodedPath}`}`;
116
+ };
117
+
118
+ const handleOpen = () => {
119
+ const target = openTool === 'antigravity'
120
+ ? `antigravity://file${card.directory}`
121
+ : buildVsCodeUri(card.directory);
122
+ window.location.href = target;
123
+ };
124
+
125
+ const handleArchive = async () => {
126
+ try {
127
+ await fetch(`/api/sessions/${card.id}/archive`, { method: 'POST' });
128
+ } finally {
129
+ setActionOpen(false);
130
+ queryClient.invalidateQueries({ queryKey: ['sessions'] });
131
+ }
132
+ };
133
+
134
+ const handleDelete = async () => {
135
+ try {
136
+ await fetch(`/api/sessions/${card.id}/delete`, { method: 'POST' });
137
+ } finally {
138
+ setActionOpen(false);
139
+ queryClient.invalidateQueries({ queryKey: ['sessions'] });
140
+ }
141
+ };
142
+
143
+ return (
144
+ <article
145
+ className="relative w-full text-left p-4 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"
146
+ >
147
+ <button
148
+ type="button"
149
+ className="w-full text-left pr-24"
150
+ onDoubleClick={handleOpen}
151
+ onClick={(event) => event.stopPropagation()}
152
+ >
153
+ {/* Top: Status indicator */}
154
+ <div className="flex items-center justify-between mb-2">
155
+ <StatusIndicator status={card.opencodeStatus} waitingForUser={card.waitingForUser} />
156
+ </div>
157
+ <h3
158
+ className="font-semibold text-gray-900 dark:text-gray-100 text-base line-clamp-2"
159
+ title={card.title || 'Untitled Session'}
160
+ >
161
+ {card.title || 'Untitled Session'}
162
+ </h3>
163
+
164
+ {card.agents.length > 0 && (
165
+ <div className="flex flex-wrap gap-1 mt-2">
166
+ {card.agents.map((agent) => (
167
+ <span
168
+ key={agent}
169
+ className="px-2 py-0.5 bg-indigo-50 text-indigo-700 text-sm rounded-full font-medium"
170
+ >
171
+ {agent}
172
+ </span>
173
+ ))}
174
+ </div>
175
+ )}
176
+ {card.projectName && (
177
+ <div className="mt-2 flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
178
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" role="img" aria-hidden="true">
179
+ <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" />
180
+ </svg>
181
+ <span className="font-medium truncate">{card.projectName}</span>
182
+ {card.branch && (
183
+ <>
184
+ <span className="text-gray-400">/</span>
185
+ <span className="text-xs bg-gray-100 dark:bg-zinc-700 px-2 py-0.5 rounded">
186
+ {card.branch}
187
+ </span>
188
+ </>
189
+ )}
190
+ </div>
191
+ )}
192
+
193
+ <div className="mt-2 flex flex-col gap-1">
194
+ <span className="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400">
195
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" role="img" aria-hidden="true">
196
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
197
+ </svg>
198
+ {lastActiveLabel}
199
+ </span>
200
+ <span className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-500">
201
+ <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" role="img" aria-hidden="true">
202
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
203
+ </svg>
204
+ {startedLabel}
205
+ </span>
206
+ </div>
207
+
208
+ {card.todosTotal > 0 && (
209
+ <div className="mt-3">
210
+ <div className="flex items-center justify-between text-sm mb-1">
211
+ <span className="text-gray-600 text-sm">Todos</span>
212
+ <span className="text-gray-600 font-medium">
213
+ {card.todosCompleted}/{card.todosTotal}
214
+ </span>
215
+ </div>
216
+ <div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
217
+ <div
218
+ className="h-full bg-emerald-500 rounded-full transition-all duration-300"
219
+ style={{ width: `${todoProgress}%` }}
220
+ />
221
+ </div>
222
+ </div>
223
+ )}
224
+ </button>
225
+ <div className="absolute top-4 right-4 flex items-center gap-2">
226
+ <select
227
+ className="text-xs rounded-md border border-gray-200 bg-white px-2 py-1 text-gray-600 shadow-sm hover:border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-gray-300"
228
+ value={openTool}
229
+ onClick={(e) => e.stopPropagation()}
230
+ onDoubleClick={(e) => e.stopPropagation()}
231
+ onChange={(e) => {
232
+ const value = e.target.value;
233
+ setOpenTool(value);
234
+ window.localStorage.setItem('vibepulse:open-tool', value);
235
+ }}
236
+ aria-label="Open tool"
237
+ title="Open tool"
238
+ >
239
+ <option value="vscode">VSCode</option>
240
+ <option value="antigravity">Antigravity</option>
241
+ </select>
242
+ {openTool === 'vscode' && remoteSshHost && (
243
+ <span className="text-[10px] text-gray-500 dark:text-gray-400" title={`SSH host: ${remoteSshHost}`}>
244
+ SSH: {remoteSshHost}
245
+ </span>
246
+ )}
247
+ <div className="relative" ref={actionMenuRef}>
248
+ <button
249
+ type="button"
250
+ className="inline-flex items-center justify-center w-6 h-6 rounded-md text-gray-400 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-500 dark:hover:text-gray-200 dark:hover:bg-zinc-700"
251
+ onClick={(e) => {
252
+ e.stopPropagation();
253
+ setActionOpen((prev) => !prev);
254
+ }}
255
+ onDoubleClick={(e) => e.stopPropagation()}
256
+ aria-label="Actions"
257
+ title="Actions"
258
+ >
259
+ <svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" role="img" aria-hidden="true">
260
+ <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" />
261
+ </svg>
262
+ </button>
263
+ {actionOpen && (
264
+ <div className="absolute right-0 mt-1 w-36 rounded-md border border-gray-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-900 z-10">
265
+ <button
266
+ type="button"
267
+ className="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-zinc-800"
268
+ onClick={(e) => {
269
+ e.stopPropagation();
270
+ handleArchive();
271
+ }}
272
+ >
273
+ Archive
274
+ </button>
275
+ <button
276
+ type="button"
277
+ className="w-full text-left px-3 py-2 text-sm text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
278
+ onClick={(e) => {
279
+ e.stopPropagation();
280
+ handleDelete();
281
+ }}
282
+ >
283
+ Delete
284
+ </button>
285
+ </div>
286
+ )}
287
+ </div>
288
+ </div>
289
+ </article>
290
+ );
291
+ }
@@ -0,0 +1,60 @@
1
+ 'use client';
2
+
3
+ import { useQuery } from '@tanstack/react-query';
4
+ import { SessionCard } from './SessionCard';
5
+ import { transformSession } from '@/lib/transform';
6
+ import { OpencodeSession } from '@/types';
7
+
8
+ interface SessionListResponse {
9
+ sessions: OpencodeSession[];
10
+ }
11
+
12
+ export function SessionList() {
13
+ const { data, isLoading, error } = useQuery<SessionListResponse>({
14
+ queryKey: ['sessions'],
15
+ queryFn: async () => {
16
+ const res = await fetch('/api/sessions');
17
+ if (!res.ok) {
18
+ throw new Error('Failed to fetch sessions');
19
+ }
20
+ return res.json();
21
+ },
22
+ });
23
+
24
+ if (isLoading) {
25
+ return (
26
+ <div className="flex items-center justify-center h-32 text-gray-500">
27
+ Loading sessions...
28
+ </div>
29
+ );
30
+ }
31
+
32
+ if (error) {
33
+ return (
34
+ <div className="flex items-center justify-center h-32 text-red-500">
35
+ Error: {error.message}
36
+ </div>
37
+ );
38
+ }
39
+
40
+ const sessions = data?.sessions || [];
41
+
42
+ if (sessions.length === 0) {
43
+ return (
44
+ <div className="flex items-center justify-center h-32 text-gray-500">
45
+ No sessions found. Start a new OpenCode session to see it here.
46
+ </div>
47
+ );
48
+ }
49
+
50
+ return (
51
+ <div className="space-y-4">
52
+ {sessions.map((session) => (
53
+ <SessionCard
54
+ key={session.id}
55
+ card={transformSession(session)}
56
+ />
57
+ ))}
58
+ </div>
59
+ );
60
+ }
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { AgentConfigForm } from './AgentConfigForm';
5
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
6
+
7
+ const mockFetch = vi.fn();
8
+ global.fetch = mockFetch;
9
+
10
+ describe('AgentConfigForm - echo bug fix', () => {
11
+ let queryClient: QueryClient;
12
+
13
+ beforeEach(() => {
14
+ queryClient = new QueryClient({
15
+ defaultOptions: {
16
+ queries: { staleTime: 0 },
17
+ },
18
+ });
19
+ queryClient.clear();
20
+ vi.clearAllMocks();
21
+ });
22
+
23
+ it('should display new value instead of cached value after saving', async () => {
24
+ const user = userEvent.setup();
25
+
26
+ mockFetch
27
+ .mockResolvedValueOnce({
28
+ json: async () => ({
29
+ agents: { sisyphus: { model: 'anthropic/claude-3.5-sonnet', temperature: 0.5 } },
30
+ categories: {},
31
+ }),
32
+ ok: true,
33
+ })
34
+ .mockResolvedValueOnce({ json: async () => ({ success: true }), ok: true })
35
+ .mockResolvedValueOnce({
36
+ json: async () => ({
37
+ agents: { sisyphus: { model: 'openai/gpt-4o', temperature: 0.8 } },
38
+ categories: {},
39
+ }),
40
+ ok: true,
41
+ });
42
+
43
+ render(
44
+ <QueryClientProvider client={queryClient}>
45
+ <AgentConfigForm agentName="sisyphus" />
46
+ </QueryClientProvider>
47
+ );
48
+
49
+ await waitFor(() => {
50
+ const tempInput = screen.getByLabelText(/temperature value/i);
51
+ expect(tempInput).toHaveValue(0.5);
52
+ });
53
+
54
+ const tempInput = screen.getByLabelText(/temperature value/i);
55
+ await user.clear(tempInput);
56
+ await user.type(tempInput, '0.8');
57
+
58
+ await user.click(screen.getByRole('button', { name: /save changes/i }));
59
+
60
+ await waitFor(() => {
61
+ const updatedTempInput = screen.getByLabelText(/temperature value/i);
62
+ expect(updatedTempInput).toHaveValue(0.8);
63
+ });
64
+ });
65
+
66
+ });