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.
- package/README.md +7 -13
- package/bin/vibepulse.js +1 -0
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/docs/session-status-detection.md +258 -0
- package/next.config.ts +11 -0
- package/package.json +17 -11
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/readme-cover.png +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/src/app/api/opencode-config/route.ts +304 -0
- package/src/app/api/opencode-config/status/route.ts +31 -0
- package/src/app/api/opencode-events/route.ts +86 -0
- package/src/app/api/opencode-models/route.test.ts +135 -0
- package/src/app/api/opencode-models/route.ts +58 -0
- package/src/app/api/profiles/[id]/apply/route.ts +49 -0
- package/src/app/api/profiles/[id]/route.ts +160 -0
- package/src/app/api/profiles/route.ts +107 -0
- package/src/app/api/sessions/[id]/archive/route.ts +35 -0
- package/src/app/api/sessions/[id]/delete/route.ts +26 -0
- package/src/app/api/sessions/[id]/route.ts +45 -0
- package/src/app/api/sessions/route.ts +596 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +66 -0
- package/src/app/layout.tsx +37 -0
- package/src/app/page.tsx +239 -0
- package/src/components/ErrorBoundary.tsx +72 -0
- package/src/components/KanbanBoard.tsx +442 -0
- package/src/components/LoadingState.tsx +37 -0
- package/src/components/ProjectCard.tsx +382 -0
- package/src/components/QueryProvider.tsx +25 -0
- package/src/components/SessionCard.tsx +291 -0
- package/src/components/SessionList.tsx +60 -0
- package/src/components/opencode-config/AgentConfigForm.test.tsx +66 -0
- package/src/components/opencode-config/AgentConfigForm.tsx +445 -0
- package/src/components/opencode-config/AgentModelSelector.tsx +284 -0
- package/src/components/opencode-config/AgentsConfigPanel.tsx +162 -0
- package/src/components/opencode-config/ConfigButton.tsx +43 -0
- package/src/components/opencode-config/ConfigPanel.tsx +91 -0
- package/src/components/opencode-config/FullscreenConfigPanel.tsx +360 -0
- package/src/components/opencode-config/categories/CategoriesList.tsx +328 -0
- package/src/components/opencode-config/categories/CategoriesManager.test.tsx +97 -0
- package/src/components/opencode-config/categories/CategoriesManager.tsx +174 -0
- package/src/components/opencode-config/categories/CategoryConfigForm.tsx +384 -0
- package/src/components/opencode-config/profiles/ProfileCard.tsx +140 -0
- package/src/components/opencode-config/profiles/ProfileEditor.tsx +446 -0
- package/src/components/opencode-config/profiles/ProfileList.tsx +398 -0
- package/src/components/opencode-config/profiles/ProfileManager.test.tsx +122 -0
- package/src/components/opencode-config/profiles/ProfileManager.tsx +293 -0
- package/src/components/ui/Tabs.tsx +59 -0
- package/src/hooks/useOpencodeSync.ts +378 -0
- package/src/index.ts +2 -0
- package/src/lib/notificationSound.ts +266 -0
- package/src/lib/opencodeConfig.test.ts +81 -0
- package/src/lib/opencodeConfig.ts +48 -0
- package/src/lib/opencodeDiscovery.ts +154 -0
- package/src/lib/profiles/storage.ts +264 -0
- package/src/lib/transform.ts +84 -0
- package/src/test/setup.ts +8 -0
- package/src/types/index.ts +89 -0
- package/src/types/opencodeConfig.ts +133 -0
- package/src/types/testing-library-vitest.d.ts +17 -0
- package/tsconfig.json +34 -0
- 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
|
+
}
|