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,442 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useQuery } from '@tanstack/react-query';
|
|
4
|
+
import { KanbanColumn, KanbanCard, OpencodeSession } from '@/types';
|
|
5
|
+
import { ProjectCard } from './ProjectCard';
|
|
6
|
+
import { transformSessions } from '@/lib/transform';
|
|
7
|
+
import { LoadingState } from './LoadingState';
|
|
8
|
+
import { playAttentionSound, playCompleteSound } from '@/lib/notificationSound';
|
|
9
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
10
|
+
|
|
11
|
+
const WAITING_STORAGE_KEY = 'vibepulse:waiting-sessions';
|
|
12
|
+
const SNAPSHOT_STORAGE_KEY = 'vibepulse:last-sessions-snapshot';
|
|
13
|
+
const START_COMMAND_TEMPLATE = 'opencode --port <PORT>';
|
|
14
|
+
const CARD_ANIMATION_DURATION_MS = 250;
|
|
15
|
+
|
|
16
|
+
const COLUMNS: { id: KanbanColumn; title: string }[] = [
|
|
17
|
+
{ id: 'idle', title: 'Idle' },
|
|
18
|
+
{ id: 'busy', title: 'Busy' },
|
|
19
|
+
{ id: 'review', title: 'Needs Attention' },
|
|
20
|
+
{ id: 'done', title: 'Archived' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
interface KanbanBoardProps {
|
|
24
|
+
filterDays: number;
|
|
25
|
+
onProcessHintsChange?: (hints: ProcessHint[]) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type SessionsFetchError = Error & {
|
|
29
|
+
kind?: 'opencode_unavailable' | 'request_failed';
|
|
30
|
+
hint?: string;
|
|
31
|
+
status?: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type ProcessHint = {
|
|
35
|
+
pid: number;
|
|
36
|
+
directory: string;
|
|
37
|
+
projectName: string;
|
|
38
|
+
reason: 'process_without_api_port';
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type SessionSnapshot = {
|
|
42
|
+
savedAt: number;
|
|
43
|
+
sessions: OpencodeSession[];
|
|
44
|
+
processHints: ProcessHint[];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type SessionsResponse = {
|
|
48
|
+
sessions: OpencodeSession[];
|
|
49
|
+
processHints?: ProcessHint[];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function KanbanBoard({ filterDays, onProcessHintsChange }: KanbanBoardProps) {
|
|
53
|
+
const waitingStateRef = useRef<Record<string, boolean>>({});
|
|
54
|
+
const waitingInitRef = useRef(false);
|
|
55
|
+
const statusStateRef = useRef<Record<string, 'idle' | 'busy' | 'retry'>>({});
|
|
56
|
+
const statusInitRef = useRef(false);
|
|
57
|
+
const [copyFeedback, setCopyFeedback] = useState<'idle' | 'copied' | 'failed'>('idle');
|
|
58
|
+
const [staleSnapshot, setStaleSnapshot] = useState<SessionSnapshot | null>(null);
|
|
59
|
+
|
|
60
|
+
const { data, isLoading, error, dataUpdatedAt, refetch, isFetching } = useQuery<SessionsResponse>({
|
|
61
|
+
queryKey: ['sessions'],
|
|
62
|
+
queryFn: async () => {
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch('/api/sessions');
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
let payload: { error?: string; hint?: string } | null = null;
|
|
67
|
+
try {
|
|
68
|
+
payload = await res.json();
|
|
69
|
+
} catch {
|
|
70
|
+
payload = null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const isUnavailable =
|
|
74
|
+
res.status === 503 && payload?.error === 'OpenCode server not found';
|
|
75
|
+
const fetchError = new Error(
|
|
76
|
+
isUnavailable
|
|
77
|
+
? payload?.error || 'OpenCode server not found'
|
|
78
|
+
: payload?.error || `Failed to load sessions (${res.status})`
|
|
79
|
+
) as SessionsFetchError;
|
|
80
|
+
|
|
81
|
+
fetchError.kind = isUnavailable ? 'opencode_unavailable' : 'request_failed';
|
|
82
|
+
fetchError.hint = payload?.hint;
|
|
83
|
+
fetchError.status = res.status;
|
|
84
|
+
throw fetchError;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return res.json();
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (error instanceof Error && (error as SessionsFetchError).kind) {
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const fetchError = new Error('Unable to connect to session service') as SessionsFetchError;
|
|
94
|
+
fetchError.kind = 'request_failed';
|
|
95
|
+
throw fetchError;
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
refetchInterval: 5000,
|
|
99
|
+
refetchIntervalInBackground: true,
|
|
100
|
+
refetchOnReconnect: true,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const activeError = error as SessionsFetchError | null;
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (typeof window === 'undefined') return;
|
|
107
|
+
try {
|
|
108
|
+
const raw = localStorage.getItem(SNAPSHOT_STORAGE_KEY);
|
|
109
|
+
if (!raw) return;
|
|
110
|
+
const parsed = JSON.parse(raw) as SessionSnapshot;
|
|
111
|
+
if (!parsed || !Array.isArray(parsed.sessions) || typeof parsed.savedAt !== 'number') {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (!Array.isArray(parsed.processHints)) {
|
|
115
|
+
parsed.processHints = [];
|
|
116
|
+
}
|
|
117
|
+
if (parsed.sessions.length === 0) return;
|
|
118
|
+
setStaleSnapshot(parsed);
|
|
119
|
+
} catch {
|
|
120
|
+
setStaleSnapshot(null);
|
|
121
|
+
}
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (typeof window === 'undefined') return;
|
|
126
|
+
if (!data?.sessions || data.sessions.length === 0) return;
|
|
127
|
+
|
|
128
|
+
const snapshot: SessionSnapshot = {
|
|
129
|
+
savedAt: Date.now(),
|
|
130
|
+
sessions: data.sessions,
|
|
131
|
+
processHints: data.processHints ?? [],
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
localStorage.setItem(SNAPSHOT_STORAGE_KEY, JSON.stringify(snapshot));
|
|
136
|
+
setStaleSnapshot(snapshot);
|
|
137
|
+
} catch {
|
|
138
|
+
setStaleSnapshot(snapshot);
|
|
139
|
+
}
|
|
140
|
+
}, [data?.processHints, data?.sessions]);
|
|
141
|
+
|
|
142
|
+
const handleCopyStartCommand = async () => {
|
|
143
|
+
try {
|
|
144
|
+
await navigator.clipboard.writeText(START_COMMAND_TEMPLATE);
|
|
145
|
+
setCopyFeedback('copied');
|
|
146
|
+
setTimeout(() => setCopyFeedback('idle'), 1500);
|
|
147
|
+
} catch {
|
|
148
|
+
setCopyFeedback('failed');
|
|
149
|
+
setTimeout(() => setCopyFeedback('idle'), 2000);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const sourceSessions = useMemo(() => {
|
|
154
|
+
if (data?.sessions) return data.sessions;
|
|
155
|
+
if (activeError && staleSnapshot?.sessions?.length) {
|
|
156
|
+
return staleSnapshot.sessions;
|
|
157
|
+
}
|
|
158
|
+
return [];
|
|
159
|
+
}, [activeError, data?.sessions, staleSnapshot?.sessions]);
|
|
160
|
+
|
|
161
|
+
const isShowingStaleData = !!activeError && !data?.sessions && !!staleSnapshot?.sessions?.length;
|
|
162
|
+
|
|
163
|
+
const processHints = useMemo(() => {
|
|
164
|
+
if (data?.processHints) {
|
|
165
|
+
return data.processHints;
|
|
166
|
+
}
|
|
167
|
+
if (isShowingStaleData && staleSnapshot?.processHints) {
|
|
168
|
+
return staleSnapshot.processHints;
|
|
169
|
+
}
|
|
170
|
+
return [];
|
|
171
|
+
}, [data?.processHints, isShowingStaleData, staleSnapshot?.processHints]);
|
|
172
|
+
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
onProcessHintsChange?.(processHints);
|
|
175
|
+
}, [onProcessHintsChange, processHints]);
|
|
176
|
+
|
|
177
|
+
const enrichedSessions = useMemo(() => {
|
|
178
|
+
if (!sourceSessions.length) return [];
|
|
179
|
+
|
|
180
|
+
let persistedWaiting: Record<string, boolean> = {};
|
|
181
|
+
if (typeof window !== 'undefined') {
|
|
182
|
+
try {
|
|
183
|
+
persistedWaiting = JSON.parse(localStorage.getItem(WAITING_STORAGE_KEY) || '{}');
|
|
184
|
+
} catch {
|
|
185
|
+
persistedWaiting = {};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return sourceSessions.map((s) => {
|
|
190
|
+
const persisted = !!persistedWaiting[s.id];
|
|
191
|
+
return {
|
|
192
|
+
...s,
|
|
193
|
+
waitingForUser: !!s.waitingForUser || (s.realTimeStatus === 'retry' && persisted),
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
}, [sourceSessions]);
|
|
197
|
+
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
if (typeof window === 'undefined') return;
|
|
200
|
+
const nextPersistedWaiting: Record<string, boolean> = {};
|
|
201
|
+
for (const session of enrichedSessions as Array<{ id: string; waitingForUser?: boolean }>) {
|
|
202
|
+
if (session.waitingForUser) {
|
|
203
|
+
nextPersistedWaiting[session.id] = true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
localStorage.setItem(WAITING_STORAGE_KEY, JSON.stringify(nextPersistedWaiting));
|
|
207
|
+
}, [enrichedSessions]);
|
|
208
|
+
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
const nextWaiting: Record<string, boolean> = {};
|
|
211
|
+
let shouldPlayAttention = false;
|
|
212
|
+
|
|
213
|
+
for (const session of enrichedSessions as Array<{ id: string; waitingForUser?: boolean }>) {
|
|
214
|
+
const waiting = !!session.waitingForUser;
|
|
215
|
+
nextWaiting[session.id] = waiting;
|
|
216
|
+
|
|
217
|
+
if (waitingInitRef.current && waiting && !waitingStateRef.current[session.id]) {
|
|
218
|
+
shouldPlayAttention = true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
waitingStateRef.current = nextWaiting;
|
|
223
|
+
|
|
224
|
+
if (!waitingInitRef.current) {
|
|
225
|
+
waitingInitRef.current = true;
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (shouldPlayAttention) {
|
|
230
|
+
setTimeout(() => playAttentionSound(), CARD_ANIMATION_DURATION_MS);
|
|
231
|
+
}
|
|
232
|
+
}, [enrichedSessions]);
|
|
233
|
+
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
const nextStatus: Record<string, 'idle' | 'busy' | 'retry'> = {};
|
|
236
|
+
|
|
237
|
+
for (const session of enrichedSessions as Array<{ id: string; realTimeStatus?: string }>) {
|
|
238
|
+
const normalized =
|
|
239
|
+
session.realTimeStatus === 'busy' || session.realTimeStatus === 'retry'
|
|
240
|
+
? session.realTimeStatus
|
|
241
|
+
: 'idle';
|
|
242
|
+
nextStatus[session.id] = normalized;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!statusInitRef.current) {
|
|
246
|
+
statusInitRef.current = true;
|
|
247
|
+
statusStateRef.current = nextStatus;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let shouldPlayComplete = false;
|
|
252
|
+
|
|
253
|
+
for (const [id, currentStatus] of Object.entries(nextStatus)) {
|
|
254
|
+
const previousStatus = statusStateRef.current[id];
|
|
255
|
+
if ((previousStatus === 'busy' || previousStatus === 'retry') && currentStatus === 'idle') {
|
|
256
|
+
shouldPlayComplete = true;
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
statusStateRef.current = nextStatus;
|
|
262
|
+
|
|
263
|
+
if (shouldPlayComplete && !isShowingStaleData) {
|
|
264
|
+
setTimeout(() => playCompleteSound(), CARD_ANIMATION_DURATION_MS);
|
|
265
|
+
}
|
|
266
|
+
}, [enrichedSessions, isShowingStaleData]);
|
|
267
|
+
|
|
268
|
+
const cards: KanbanCard[] = useMemo(() => {
|
|
269
|
+
const allCards = transformSessions(enrichedSessions);
|
|
270
|
+
if (filterDays === 0) {
|
|
271
|
+
return allCards;
|
|
272
|
+
}
|
|
273
|
+
const cutoff = dataUpdatedAt - filterDays * 24 * 60 * 60 * 1000;
|
|
274
|
+
return allCards.filter((card) => card.updatedAt >= cutoff);
|
|
275
|
+
}, [dataUpdatedAt, enrichedSessions, filterDays]);
|
|
276
|
+
|
|
277
|
+
if (isLoading) {
|
|
278
|
+
return <LoadingState />;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (error && !isShowingStaleData) {
|
|
282
|
+
const isOpencodeUnavailable = activeError?.kind === 'opencode_unavailable';
|
|
283
|
+
const title = isOpencodeUnavailable ? 'OpenCode is not running' : 'Failed to load sessions';
|
|
284
|
+
const description = isOpencodeUnavailable
|
|
285
|
+
? activeError?.hint || 'Run OpenCode with an exposed API port, for example `opencode --port <PORT>`.'
|
|
286
|
+
: activeError?.message || 'An error occurred while loading sessions';
|
|
287
|
+
|
|
288
|
+
return (
|
|
289
|
+
<div className="flex-1 flex items-center justify-center p-8">
|
|
290
|
+
<div className="max-w-md w-full text-center">
|
|
291
|
+
<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">
|
|
292
|
+
<svg
|
|
293
|
+
className="w-6 h-6 text-red-600 dark:text-red-400"
|
|
294
|
+
fill="none"
|
|
295
|
+
stroke="currentColor"
|
|
296
|
+
viewBox="0 0 24 24"
|
|
297
|
+
aria-hidden="true"
|
|
298
|
+
>
|
|
299
|
+
<path
|
|
300
|
+
strokeLinecap="round"
|
|
301
|
+
strokeLinejoin="round"
|
|
302
|
+
strokeWidth={2}
|
|
303
|
+
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
304
|
+
/>
|
|
305
|
+
</svg>
|
|
306
|
+
</div>
|
|
307
|
+
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
|
308
|
+
{title}
|
|
309
|
+
</h2>
|
|
310
|
+
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
|
311
|
+
{description}
|
|
312
|
+
</p>
|
|
313
|
+
<div className="flex items-center justify-center gap-2">
|
|
314
|
+
<button
|
|
315
|
+
type="button"
|
|
316
|
+
onClick={() => refetch()}
|
|
317
|
+
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
318
|
+
disabled={isFetching}
|
|
319
|
+
>
|
|
320
|
+
{isFetching ? 'Retrying...' : 'Retry'}
|
|
321
|
+
</button>
|
|
322
|
+
{isOpencodeUnavailable ? (
|
|
323
|
+
<button
|
|
324
|
+
type="button"
|
|
325
|
+
onClick={handleCopyStartCommand}
|
|
326
|
+
className="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-gray-700 dark:text-gray-300 text-sm font-medium rounded-md transition-colors"
|
|
327
|
+
>
|
|
328
|
+
{copyFeedback === 'copied'
|
|
329
|
+
? 'Copied'
|
|
330
|
+
: copyFeedback === 'failed'
|
|
331
|
+
? 'Copy Failed'
|
|
332
|
+
: 'Copy Start Command'}
|
|
333
|
+
</button>
|
|
334
|
+
) : null}
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (!cards || cards.length === 0) {
|
|
342
|
+
return (
|
|
343
|
+
<div className="flex-1 flex items-center justify-center p-8">
|
|
344
|
+
<div className="max-w-md w-full text-center">
|
|
345
|
+
<div className="flex items-center justify-center w-12 h-12 mx-auto mb-4 bg-gray-100 dark:bg-zinc-800 rounded-full">
|
|
346
|
+
<svg
|
|
347
|
+
className="w-6 h-6 text-gray-500 dark:text-gray-400"
|
|
348
|
+
fill="none"
|
|
349
|
+
stroke="currentColor"
|
|
350
|
+
viewBox="0 0 24 24"
|
|
351
|
+
aria-hidden="true"
|
|
352
|
+
>
|
|
353
|
+
<path
|
|
354
|
+
strokeLinecap="round"
|
|
355
|
+
strokeLinejoin="round"
|
|
356
|
+
strokeWidth={2}
|
|
357
|
+
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
|
358
|
+
/>
|
|
359
|
+
</svg>
|
|
360
|
+
</div>
|
|
361
|
+
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
|
362
|
+
No sessions yet
|
|
363
|
+
</h2>
|
|
364
|
+
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
|
365
|
+
OpenCode is running, but no sessions are available.
|
|
366
|
+
</p>
|
|
367
|
+
<p className="text-sm text-gray-500 dark:text-gray-500">
|
|
368
|
+
Start a conversation in OpenCode and this board will update automatically.
|
|
369
|
+
</p>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Group cards by project
|
|
376
|
+
const groupByProject = (columnCards: KanbanCard[]) => {
|
|
377
|
+
const groups = new Map<string, KanbanCard[]>();
|
|
378
|
+
for (const card of columnCards) {
|
|
379
|
+
const key = card.projectName || 'Unknown Project';
|
|
380
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
381
|
+
groups.get(key)!.push(card);
|
|
382
|
+
}
|
|
383
|
+
return groups;
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
return (
|
|
387
|
+
<div className="flex-1 overflow-x-auto scrollbar-thin scroll-smooth">
|
|
388
|
+
{isShowingStaleData ? (
|
|
389
|
+
<div className="px-4 pt-4 pb-0">
|
|
390
|
+
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-amber-800 dark:border-amber-900/40 dark:bg-amber-900/20 dark:text-amber-200">
|
|
391
|
+
<div className="flex items-center gap-2 text-xs font-medium">
|
|
392
|
+
<span className="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-[10px] uppercase tracking-wide dark:bg-amber-900/40">
|
|
393
|
+
Stale Data
|
|
394
|
+
</span>
|
|
395
|
+
<span>
|
|
396
|
+
Last seen at {staleSnapshot ? new Date(staleSnapshot.savedAt).toLocaleString() : '--'}
|
|
397
|
+
</span>
|
|
398
|
+
<span className="text-amber-700/80 dark:text-amber-300/80">Read-only snapshot while OpenCode is unreachable.</span>
|
|
399
|
+
</div>
|
|
400
|
+
</div>
|
|
401
|
+
</div>
|
|
402
|
+
) : null}
|
|
403
|
+
<div className="flex gap-6 h-full min-w-max p-4">
|
|
404
|
+
{COLUMNS.map((column) => {
|
|
405
|
+
const columnCards = cards
|
|
406
|
+
.filter((c) => c.status === column.id)
|
|
407
|
+
.sort((a, b) => a.sortOrder - b.sortOrder);
|
|
408
|
+
const projectGroups = groupByProject(columnCards);
|
|
409
|
+
|
|
410
|
+
return (
|
|
411
|
+
<div
|
|
412
|
+
key={column.id}
|
|
413
|
+
className="flex-shrink-0 w-80 bg-gray-100 dark:bg-zinc-800/80 rounded-xl p-4 flex flex-col shadow-sm"
|
|
414
|
+
>
|
|
415
|
+
<div className="flex items-center justify-between mb-4 pb-3 border-b border-gray-200 dark:border-zinc-700">
|
|
416
|
+
<h2 className="font-semibold text-gray-700 dark:text-gray-300">
|
|
417
|
+
{column.title}
|
|
418
|
+
</h2>
|
|
419
|
+
<span className="px-2.5 py-0.5 bg-gray-200 dark:bg-zinc-700 text-gray-600 dark:text-gray-400 text-xs font-medium rounded-full">
|
|
420
|
+
{columnCards.length}
|
|
421
|
+
</span>
|
|
422
|
+
</div>
|
|
423
|
+
<div className="flex-1 overflow-y-auto scrollbar-thin pr-1">
|
|
424
|
+
<div className="space-y-3">
|
|
425
|
+
{Array.from(projectGroups.entries()).map(([projectName, groupCards]) => (
|
|
426
|
+
<ProjectCard
|
|
427
|
+
key={projectName}
|
|
428
|
+
projectName={projectName}
|
|
429
|
+
branch={groupCards[0].branch}
|
|
430
|
+
cards={groupCards}
|
|
431
|
+
readOnly={isShowingStaleData}
|
|
432
|
+
/>
|
|
433
|
+
))}
|
|
434
|
+
</div>
|
|
435
|
+
</div>
|
|
436
|
+
</div>
|
|
437
|
+
);
|
|
438
|
+
})}
|
|
439
|
+
</div>
|
|
440
|
+
</div>
|
|
441
|
+
);
|
|
442
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
export function LoadingState() {
|
|
4
|
+
return (
|
|
5
|
+
<div className="h-[calc(100vh-8rem)] overflow-x-auto">
|
|
6
|
+
<div className="flex gap-4 h-full min-w-max p-4">
|
|
7
|
+
{/* Render 4 column skeletons matching the Kanban columns */}
|
|
8
|
+
{[1, 2, 3, 4].map((col) => (
|
|
9
|
+
<div
|
|
10
|
+
key={col}
|
|
11
|
+
className="flex-shrink-0 w-80 bg-gray-100 dark:bg-zinc-800 rounded-lg p-4 animate-pulse"
|
|
12
|
+
>
|
|
13
|
+
{/* Column header skeleton */}
|
|
14
|
+
<div className="h-6 bg-gray-200 dark:bg-zinc-700 rounded w-24 mb-4" />
|
|
15
|
+
{/* Card skeletons */}
|
|
16
|
+
<div className="space-y-3">
|
|
17
|
+
{[1, 2, 3].map((card) => (
|
|
18
|
+
<div
|
|
19
|
+
key={card}
|
|
20
|
+
className="p-4 bg-white dark:bg-zinc-900 rounded-lg shadow border border-gray-200 dark:border-zinc-700"
|
|
21
|
+
>
|
|
22
|
+
{/* Title skeleton */}
|
|
23
|
+
<div className="h-5 bg-gray-200 dark:bg-zinc-700 rounded w-3/4 mb-3" />
|
|
24
|
+
{/* Metadata skeleton */}
|
|
25
|
+
<div className="flex gap-2">
|
|
26
|
+
<div className="h-4 bg-gray-200 dark:bg-zinc-700 rounded w-20" />
|
|
27
|
+
<div className="h-4 bg-gray-200 dark:bg-zinc-700 rounded w-16" />
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
))}
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
))}
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|