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
package/src/app/page.tsx
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useMemo, useRef } from 'react';
|
|
4
|
+
import { KanbanBoard } from "@/components/KanbanBoard";
|
|
5
|
+
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
|
6
|
+
import { useOpencodeSync } from "@/hooks/useOpencodeSync";
|
|
7
|
+
import { isMuted, playToggleFeedbackSound, setMuted, unlockAudio } from "@/lib/notificationSound";
|
|
8
|
+
import { Info } from 'lucide-react';
|
|
9
|
+
import { ConfigButton } from "@/components/opencode-config/ConfigButton";
|
|
10
|
+
import { FullscreenConfigPanel } from "@/components/opencode-config/FullscreenConfigPanel";
|
|
11
|
+
|
|
12
|
+
const DATE_FILTERS = [
|
|
13
|
+
{ label: '1d', days: 1 },
|
|
14
|
+
{ label: '3d', days: 3 },
|
|
15
|
+
{ label: '7d', days: 7 },
|
|
16
|
+
{ label: '30d', days: 30 },
|
|
17
|
+
{ label: 'All', days: 0 },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const START_COMMAND_TEMPLATE = 'opencode --port <PORT>';
|
|
21
|
+
|
|
22
|
+
type ProcessHint = {
|
|
23
|
+
pid: number;
|
|
24
|
+
directory: string;
|
|
25
|
+
projectName: string;
|
|
26
|
+
reason: 'process_without_api_port';
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export default function Home() {
|
|
30
|
+
useOpencodeSync();
|
|
31
|
+
const [filterDays, setFilterDays] = useState(7);
|
|
32
|
+
const [muted, setMutedState] = useState(() => isMuted());
|
|
33
|
+
const [processHints, setProcessHints] = useState<ProcessHint[]>([]);
|
|
34
|
+
const [isProcessHintOpen, setIsProcessHintOpen] = useState(false);
|
|
35
|
+
const [copyFeedback, setCopyFeedback] = useState<'idle' | 'copied' | 'failed'>('idle');
|
|
36
|
+
const [configPanelOpen, setConfigPanelOpen] = useState(false);
|
|
37
|
+
const processHintButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
38
|
+
const processHintPopoverRef = useRef<HTMLDivElement | null>(null);
|
|
39
|
+
|
|
40
|
+
const processHintProjects = useMemo(
|
|
41
|
+
() => Array.from(new Set(processHints.map((hint) => hint.projectName))),
|
|
42
|
+
[processHints]
|
|
43
|
+
);
|
|
44
|
+
const hasProcessHints = processHints.length > 0;
|
|
45
|
+
|
|
46
|
+
const processHintSummary = processHintProjects.length === 1
|
|
47
|
+
? `${processHintProjects[0]} has an OpenCode process without an exposed API port.`
|
|
48
|
+
: `${processHintProjects.length} projects have OpenCode processes without exposed API ports.`;
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const unlock = () => {
|
|
52
|
+
unlockAudio();
|
|
53
|
+
document.removeEventListener('click', unlock);
|
|
54
|
+
document.removeEventListener('pointerdown', unlock);
|
|
55
|
+
document.removeEventListener('keydown', unlock);
|
|
56
|
+
document.removeEventListener('touchstart', unlock);
|
|
57
|
+
};
|
|
58
|
+
document.addEventListener('click', unlock);
|
|
59
|
+
document.addEventListener('pointerdown', unlock, { passive: true });
|
|
60
|
+
document.addEventListener('keydown', unlock);
|
|
61
|
+
document.addEventListener('touchstart', unlock, { passive: true });
|
|
62
|
+
return () => {
|
|
63
|
+
document.removeEventListener('click', unlock);
|
|
64
|
+
document.removeEventListener('pointerdown', unlock);
|
|
65
|
+
document.removeEventListener('keydown', unlock);
|
|
66
|
+
document.removeEventListener('touchstart', unlock);
|
|
67
|
+
};
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!isProcessHintOpen || !hasProcessHints) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const handlePointerDown = (event: MouseEvent) => {
|
|
76
|
+
const target = event.target as Node;
|
|
77
|
+
const popover = processHintPopoverRef.current;
|
|
78
|
+
const button = processHintButtonRef.current;
|
|
79
|
+
|
|
80
|
+
if (popover?.contains(target) || button?.contains(target)) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
setIsProcessHintOpen(false);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
88
|
+
if (event.key !== 'Escape') {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
setIsProcessHintOpen(false);
|
|
92
|
+
processHintButtonRef.current?.focus();
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
document.addEventListener('mousedown', handlePointerDown);
|
|
96
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
97
|
+
|
|
98
|
+
return () => {
|
|
99
|
+
document.removeEventListener('mousedown', handlePointerDown);
|
|
100
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
101
|
+
};
|
|
102
|
+
}, [hasProcessHints, isProcessHintOpen]);
|
|
103
|
+
|
|
104
|
+
const toggleMute = () => {
|
|
105
|
+
unlockAudio();
|
|
106
|
+
const newMuted = !muted;
|
|
107
|
+
setMutedState(newMuted);
|
|
108
|
+
setMuted(newMuted);
|
|
109
|
+
if (!newMuted) {
|
|
110
|
+
playToggleFeedbackSound();
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const handleCopyStartCommand = async () => {
|
|
115
|
+
try {
|
|
116
|
+
await navigator.clipboard.writeText(START_COMMAND_TEMPLATE);
|
|
117
|
+
setCopyFeedback('copied');
|
|
118
|
+
setTimeout(() => setCopyFeedback('idle'), 1500);
|
|
119
|
+
} catch {
|
|
120
|
+
setCopyFeedback('failed');
|
|
121
|
+
setTimeout(() => setCopyFeedback('idle'), 2000);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div className="min-h-screen bg-zinc-50 dark:bg-black">
|
|
127
|
+
<main className="h-screen flex flex-col">
|
|
128
|
+
<header className="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-zinc-800">
|
|
129
|
+
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
|
130
|
+
VibePulse
|
|
131
|
+
</h1>
|
|
132
|
+
<div className="flex items-center gap-3">
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
onClick={toggleMute}
|
|
136
|
+
className={`p-1.5 rounded-lg transition-all duration-150 ${
|
|
137
|
+
muted
|
|
138
|
+
? 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800'
|
|
139
|
+
: 'text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/20'
|
|
140
|
+
}`}
|
|
141
|
+
title={muted ? 'Unmute notifications' : 'Mute notifications'}
|
|
142
|
+
>
|
|
143
|
+
{muted ? (
|
|
144
|
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
145
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
|
146
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2" />
|
|
147
|
+
</svg>
|
|
148
|
+
) : (
|
|
149
|
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
150
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072M18.364 5.636a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
|
151
|
+
</svg>
|
|
152
|
+
)}
|
|
153
|
+
</button>
|
|
154
|
+
{hasProcessHints ? (
|
|
155
|
+
<div className="relative">
|
|
156
|
+
<button
|
|
157
|
+
ref={processHintButtonRef}
|
|
158
|
+
type="button"
|
|
159
|
+
onClick={() => setIsProcessHintOpen((open) => !open)}
|
|
160
|
+
className={`p-1.5 rounded-lg transition-all duration-150 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-zinc-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${
|
|
161
|
+
isProcessHintOpen ? 'bg-gray-100 dark:bg-zinc-800' : ''
|
|
162
|
+
}`}
|
|
163
|
+
aria-label="OpenCode process hint details"
|
|
164
|
+
aria-haspopup="dialog"
|
|
165
|
+
aria-expanded={hasProcessHints && isProcessHintOpen}
|
|
166
|
+
aria-controls="process-hint-popover"
|
|
167
|
+
>
|
|
168
|
+
<Info className="h-3.5 w-3.5" />
|
|
169
|
+
</button>
|
|
170
|
+
{hasProcessHints && isProcessHintOpen ? (
|
|
171
|
+
<div
|
|
172
|
+
id="process-hint-popover"
|
|
173
|
+
ref={processHintPopoverRef}
|
|
174
|
+
role="dialog"
|
|
175
|
+
aria-label="OpenCode process hints"
|
|
176
|
+
className="absolute right-0 top-9 z-30 w-80 rounded-lg border border-blue-200 bg-white p-3 shadow-xl dark:border-blue-900/40 dark:bg-zinc-900"
|
|
177
|
+
>
|
|
178
|
+
<p className="text-xs font-medium text-blue-900 dark:text-blue-200">
|
|
179
|
+
{processHintSummary}
|
|
180
|
+
</p>
|
|
181
|
+
<p className="mt-1 text-[11px] leading-relaxed text-blue-800 dark:text-blue-300">
|
|
182
|
+
Sessions from these instances become visible once OpenCode starts with an exposed API port.
|
|
183
|
+
</p>
|
|
184
|
+
<div className="mt-2 rounded-md bg-blue-50 px-2 py-1.5 dark:bg-blue-900/20">
|
|
185
|
+
<code className="text-[11px] text-blue-900 dark:text-blue-200">{START_COMMAND_TEMPLATE}</code>
|
|
186
|
+
</div>
|
|
187
|
+
<div className="mt-2 flex items-center justify-between gap-2">
|
|
188
|
+
<span className="text-[10px] text-blue-700 dark:text-blue-300">
|
|
189
|
+
VibePulse auto-detects active ports.
|
|
190
|
+
</span>
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
onClick={handleCopyStartCommand}
|
|
194
|
+
className="rounded-md border border-blue-200 px-2 py-1 text-[10px] font-medium text-blue-800 transition-colors hover:bg-blue-50 dark:border-blue-900/40 dark:text-blue-200 dark:hover:bg-blue-900/30"
|
|
195
|
+
>
|
|
196
|
+
{copyFeedback === 'copied'
|
|
197
|
+
? 'Copied'
|
|
198
|
+
: copyFeedback === 'failed'
|
|
199
|
+
? 'Copy Failed'
|
|
200
|
+
: 'Copy'}
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
) : null}
|
|
205
|
+
</div>
|
|
206
|
+
) : null}
|
|
207
|
+
<svg className="w-4 h-4 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
208
|
+
<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" />
|
|
209
|
+
</svg>
|
|
210
|
+
<div className="flex items-center gap-1 bg-gray-100 dark:bg-zinc-800 rounded-lg p-0.5">
|
|
211
|
+
{DATE_FILTERS.map((f) => (
|
|
212
|
+
<button
|
|
213
|
+
key={f.days}
|
|
214
|
+
type="button"
|
|
215
|
+
onClick={() => setFilterDays(f.days)}
|
|
216
|
+
className={`px-3 py-1 text-xs font-medium rounded-md transition-all duration-150 ${
|
|
217
|
+
filterDays === f.days
|
|
218
|
+
? 'bg-white dark:bg-zinc-600 text-gray-900 dark:text-white shadow-sm'
|
|
219
|
+
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
|
220
|
+
}`}
|
|
221
|
+
>
|
|
222
|
+
{f.label}
|
|
223
|
+
</button>
|
|
224
|
+
))}
|
|
225
|
+
</div>
|
|
226
|
+
<ConfigButton onClick={() => setConfigPanelOpen(true)} />
|
|
227
|
+
</div>
|
|
228
|
+
</header>
|
|
229
|
+
<ErrorBoundary>
|
|
230
|
+
<KanbanBoard filterDays={filterDays} onProcessHintsChange={setProcessHints} />
|
|
231
|
+
</ErrorBoundary>
|
|
232
|
+
<FullscreenConfigPanel
|
|
233
|
+
open={configPanelOpen}
|
|
234
|
+
onClose={() => setConfigPanelOpen(false)}
|
|
235
|
+
/>
|
|
236
|
+
</main>
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Component, ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
interface ErrorBoundaryProps {
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface ErrorBoundaryState {
|
|
10
|
+
hasError: boolean;
|
|
11
|
+
error?: Error;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
15
|
+
constructor(props: ErrorBoundaryProps) {
|
|
16
|
+
super(props);
|
|
17
|
+
this.state = { hasError: false };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
21
|
+
return { hasError: true, error };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
|
25
|
+
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
render() {
|
|
29
|
+
if (this.state.hasError) {
|
|
30
|
+
return (
|
|
31
|
+
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-black">
|
|
32
|
+
<div className="max-w-md w-full mx-4 p-8 bg-white dark:bg-zinc-900 rounded-lg shadow-lg border border-gray-200 dark:border-zinc-800">
|
|
33
|
+
<div className="flex items-center justify-center w-12 h-12 mx-auto mb-4 bg-red-100 dark:bg-red-900/30 rounded-full">
|
|
34
|
+
<svg
|
|
35
|
+
className="w-6 h-6 text-red-600 dark:text-red-400"
|
|
36
|
+
fill="none"
|
|
37
|
+
stroke="currentColor"
|
|
38
|
+
viewBox="0 0 24 24"
|
|
39
|
+
>
|
|
40
|
+
<path
|
|
41
|
+
strokeLinecap="round"
|
|
42
|
+
strokeLinejoin="round"
|
|
43
|
+
strokeWidth={2}
|
|
44
|
+
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
|
45
|
+
/>
|
|
46
|
+
</svg>
|
|
47
|
+
</div>
|
|
48
|
+
<h2 className="text-xl font-semibold text-center text-gray-900 dark:text-white mb-2">
|
|
49
|
+
Something went wrong
|
|
50
|
+
</h2>
|
|
51
|
+
<p className="text-center text-gray-600 dark:text-gray-400 mb-6">
|
|
52
|
+
An unexpected error occurred. Please try refreshing the page.
|
|
53
|
+
</p>
|
|
54
|
+
{this.state.error && (
|
|
55
|
+
<div className="p-3 bg-gray-100 dark:bg-zinc-800 rounded text-sm text-gray-700 dark:text-gray-300 font-mono overflow-auto">
|
|
56
|
+
{this.state.error.message}
|
|
57
|
+
</div>
|
|
58
|
+
)}
|
|
59
|
+
<button
|
|
60
|
+
onClick={() => window.location.reload()}
|
|
61
|
+
className="mt-6 w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
|
|
62
|
+
>
|
|
63
|
+
Refresh Page
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return this.props.children;
|
|
71
|
+
}
|
|
72
|
+
}
|