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,596 @@
|
|
|
1
|
+
import { createOpencodeClient } from '@opencode-ai/sdk';
|
|
2
|
+
import { execSync } from 'child_process';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { discoverOpencodePorts, discoverOpencodeProcessCwdsWithoutPort } from '@/lib/opencodeDiscovery';
|
|
5
|
+
|
|
6
|
+
type SessionLike = {
|
|
7
|
+
id: string;
|
|
8
|
+
slug?: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
directory: string;
|
|
11
|
+
parentID?: string;
|
|
12
|
+
time?: {
|
|
13
|
+
created: number;
|
|
14
|
+
updated: number;
|
|
15
|
+
archived?: number;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const CHILD_ACTIVE_WINDOW_MS = 30 * 60 * 1000;
|
|
20
|
+
const CHILD_UNKNOWN_STATE_BUSY_WINDOW_MS = 2 * 60 * 1000;
|
|
21
|
+
const CHILD_STATUS_MESSAGE_CHECK_LIMIT = 50;
|
|
22
|
+
const STATUS_STICKY_BUSY_WINDOW_MS = 25 * 1000;
|
|
23
|
+
const STALL_DETECTION_WINDOW_MS = 30 * 1000;
|
|
24
|
+
const STATUS_STICKY_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
type StableRealtimeStatus = 'idle' | 'busy' | 'retry';
|
|
27
|
+
|
|
28
|
+
type StatusStickyState = {
|
|
29
|
+
lastBusyAt: number;
|
|
30
|
+
lastSeenAt: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const statusStickyState = new Map<string, StatusStickyState>();
|
|
34
|
+
|
|
35
|
+
type ChildEntry = {
|
|
36
|
+
id: string;
|
|
37
|
+
slug?: string;
|
|
38
|
+
title?: string;
|
|
39
|
+
directory?: string;
|
|
40
|
+
parentID?: string;
|
|
41
|
+
time?: { created: number; updated: number };
|
|
42
|
+
realTimeStatus: string;
|
|
43
|
+
waitingForUser: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type EnrichedSession = SessionLike & {
|
|
47
|
+
projectName: string;
|
|
48
|
+
branch: string | null;
|
|
49
|
+
realTimeStatus: 'idle' | 'busy' | 'retry';
|
|
50
|
+
waitingForUser: boolean;
|
|
51
|
+
children: ChildEntry[];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type ProcessHint = {
|
|
55
|
+
pid: number;
|
|
56
|
+
directory: string;
|
|
57
|
+
projectName: string;
|
|
58
|
+
reason: 'process_without_api_port';
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type MessageStateStatus = string;
|
|
62
|
+
|
|
63
|
+
type MessagePart = {
|
|
64
|
+
state?: {
|
|
65
|
+
status?: unknown;
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const WAITING_PART_STATUSES = new Set<string>([
|
|
70
|
+
'awaiting-input',
|
|
71
|
+
'awaiting_input',
|
|
72
|
+
'input-required',
|
|
73
|
+
'input_required',
|
|
74
|
+
'requires-input',
|
|
75
|
+
'requires_input',
|
|
76
|
+
'blocked',
|
|
77
|
+
'paused',
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
function normalizePartStatus(status: string): string {
|
|
81
|
+
return status.trim().toLowerCase();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isWaitingPartStatus(status: string): boolean {
|
|
85
|
+
return WAITING_PART_STATUSES.has(normalizePartStatus(status));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function collectPartStatuses(messages: Array<{ parts?: MessagePart[] }>): MessageStateStatus[] {
|
|
89
|
+
const partStatuses: MessageStateStatus[] = [];
|
|
90
|
+
|
|
91
|
+
for (const message of messages) {
|
|
92
|
+
for (const part of message.parts || []) {
|
|
93
|
+
const status = part?.state?.status;
|
|
94
|
+
if (typeof status === 'string') {
|
|
95
|
+
const normalized = normalizePartStatus(status);
|
|
96
|
+
if (normalized) {
|
|
97
|
+
partStatuses.push(normalized);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return partStatuses;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function fetchPartStatuses(
|
|
107
|
+
client: ReturnType<typeof createOpencodeClient>,
|
|
108
|
+
sessionId: string
|
|
109
|
+
): Promise<MessageStateStatus[]> {
|
|
110
|
+
const messagesResult = await client.session.messages({
|
|
111
|
+
path: { id: sessionId },
|
|
112
|
+
query: { limit: 8 },
|
|
113
|
+
});
|
|
114
|
+
const messages = (messagesResult.data || []) as Array<{ parts?: MessagePart[] }>;
|
|
115
|
+
return collectPartStatuses(messages);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getUpdatedAt(session: { time?: { updated?: number; created?: number } }): number {
|
|
119
|
+
return session.time?.updated || session.time?.created || 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function normalizeRealtimeStatus(value: string | undefined): StableRealtimeStatus {
|
|
123
|
+
if (value === 'busy' || value === 'retry') return value;
|
|
124
|
+
return 'idle';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function applyStickyBusyStatus(id: string, status: StableRealtimeStatus, now: number): StableRealtimeStatus {
|
|
128
|
+
const existing = statusStickyState.get(id) ?? { lastBusyAt: 0, lastSeenAt: now };
|
|
129
|
+
|
|
130
|
+
if (status === 'busy') {
|
|
131
|
+
existing.lastBusyAt = now;
|
|
132
|
+
existing.lastSeenAt = now;
|
|
133
|
+
statusStickyState.set(id, existing);
|
|
134
|
+
return status;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (status === 'retry') {
|
|
138
|
+
existing.lastSeenAt = now;
|
|
139
|
+
statusStickyState.set(id, existing);
|
|
140
|
+
return status;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const shouldKeepBusy = existing.lastBusyAt > 0 && now - existing.lastBusyAt <= STATUS_STICKY_BUSY_WINDOW_MS;
|
|
144
|
+
existing.lastSeenAt = now;
|
|
145
|
+
statusStickyState.set(id, existing);
|
|
146
|
+
return shouldKeepBusy ? 'busy' : 'idle';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function pruneStickyState(now: number): void {
|
|
150
|
+
for (const [id, state] of statusStickyState) {
|
|
151
|
+
if (now - state.lastSeenAt > STATUS_STICKY_RETENTION_MS) {
|
|
152
|
+
statusStickyState.delete(id);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function hasRecentActivity(session: { time?: { updated?: number } }, now: number): boolean {
|
|
158
|
+
const updatedAt = session.time?.updated;
|
|
159
|
+
if (!updatedAt) return false;
|
|
160
|
+
return now - updatedAt <= STALL_DETECTION_WINDOW_MS;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function toChildEntry(
|
|
164
|
+
child: SessionLike,
|
|
165
|
+
status: 'idle' | 'busy' | 'retry',
|
|
166
|
+
waitingForUser = false
|
|
167
|
+
): ChildEntry {
|
|
168
|
+
return {
|
|
169
|
+
id: child.id,
|
|
170
|
+
slug: child.slug,
|
|
171
|
+
title: child.title,
|
|
172
|
+
directory: child.directory,
|
|
173
|
+
parentID: child.parentID,
|
|
174
|
+
time: child.time,
|
|
175
|
+
realTimeStatus: status,
|
|
176
|
+
waitingForUser,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
// Get project name from directory path
|
|
180
|
+
function getProjectName(directory: string): string {
|
|
181
|
+
return path.basename(directory);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Check if directory is a git repository
|
|
185
|
+
function isGitRepo(directory: string): boolean {
|
|
186
|
+
try {
|
|
187
|
+
const result = execSync('git rev-parse --is-inside-work-tree', {
|
|
188
|
+
cwd: directory,
|
|
189
|
+
encoding: 'utf-8',
|
|
190
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
191
|
+
});
|
|
192
|
+
return result.trim() === 'true';
|
|
193
|
+
} catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Get git branch name
|
|
199
|
+
function getGitBranch(directory: string): string | null {
|
|
200
|
+
if (!isGitRepo(directory)) return null;
|
|
201
|
+
try {
|
|
202
|
+
const branch = execSync('git branch --show-current', {
|
|
203
|
+
cwd: directory,
|
|
204
|
+
encoding: 'utf-8',
|
|
205
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
206
|
+
});
|
|
207
|
+
return branch.trim() || null;
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function GET() {
|
|
214
|
+
const rawProcessHints = discoverOpencodeProcessCwdsWithoutPort();
|
|
215
|
+
const processHintsByDirectory = new Map<string, ProcessHint>();
|
|
216
|
+
for (const process of rawProcessHints) {
|
|
217
|
+
if (!process.cwd || process.cwd.startsWith('/private/tmp/opencode')) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (processHintsByDirectory.has(process.cwd)) {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
processHintsByDirectory.set(process.cwd, {
|
|
224
|
+
pid: process.pid,
|
|
225
|
+
directory: process.cwd,
|
|
226
|
+
projectName: getProjectName(process.cwd),
|
|
227
|
+
reason: 'process_without_api_port',
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const ports = discoverOpencodePorts();
|
|
232
|
+
|
|
233
|
+
if (!ports.length) {
|
|
234
|
+
const processHints = Array.from(processHintsByDirectory.values());
|
|
235
|
+
if (processHints.length > 0) {
|
|
236
|
+
return Response.json({ sessions: [], processHints });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return Response.json(
|
|
240
|
+
{
|
|
241
|
+
error: 'OpenCode server not found',
|
|
242
|
+
hint: 'Make sure OpenCode is running with an exposed API port. Example: opencode --port <PORT> (VibePulse auto-detects active ports).'
|
|
243
|
+
},
|
|
244
|
+
{ status: 503 }
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
const results = await Promise.allSettled(ports.map(async (port) => {
|
|
250
|
+
const client = createOpencodeClient({ baseUrl: `http://localhost:${port}` });
|
|
251
|
+
const sessionsResult = await client.session.list();
|
|
252
|
+
const statusResult = await client.session.status().catch(() => ({ data: {} }));
|
|
253
|
+
return { port, client, sessions: sessionsResult.data || [], status: statusResult.data || {} };
|
|
254
|
+
}));
|
|
255
|
+
|
|
256
|
+
const allSessions: SessionLike[] = [];
|
|
257
|
+
const statusMap: Record<string, { type: 'idle' | 'busy' | 'retry' }> = {};
|
|
258
|
+
const clientByPort: Record<number, ReturnType<typeof createOpencodeClient>> = {};
|
|
259
|
+
const sessionPortMap: Record<string, number> = {};
|
|
260
|
+
|
|
261
|
+
for (let i = 0; i < results.length; i++) {
|
|
262
|
+
const r = results[i];
|
|
263
|
+
if (r.status !== 'fulfilled') continue;
|
|
264
|
+
allSessions.push(...r.value.sessions);
|
|
265
|
+
Object.assign(statusMap, r.value.status);
|
|
266
|
+
clientByPort[r.value.port] = r.value.client;
|
|
267
|
+
for (const session of r.value.sessions as SessionLike[]) {
|
|
268
|
+
if (!(session.id in sessionPortMap)) {
|
|
269
|
+
sessionPortMap[session.id] = r.value.port;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Deduplicate by session.id
|
|
275
|
+
const seen = new Set<string>();
|
|
276
|
+
const sessions = allSessions.filter(s => {
|
|
277
|
+
if (seen.has(s.id)) return false;
|
|
278
|
+
seen.add(s.id);
|
|
279
|
+
return true;
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const parentSessions = sessions.filter((s) => !s.parentID);
|
|
283
|
+
const childSessions = sessions.filter((s) => !!s.parentID);
|
|
284
|
+
|
|
285
|
+
// Enrich parent sessions
|
|
286
|
+
const enrichedSessions: EnrichedSession[] = parentSessions.map((session) => {
|
|
287
|
+
const projectName = getProjectName(session.directory);
|
|
288
|
+
const branch = getGitBranch(session.directory);
|
|
289
|
+
return {
|
|
290
|
+
...session,
|
|
291
|
+
projectName,
|
|
292
|
+
branch,
|
|
293
|
+
realTimeStatus: statusMap[session.id]?.type || 'idle',
|
|
294
|
+
waitingForUser: false,
|
|
295
|
+
children: [],
|
|
296
|
+
};
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const parentById = new Map(enrichedSessions.map((session) => [session.id, session]));
|
|
300
|
+
|
|
301
|
+
const now = Date.now();
|
|
302
|
+
const unresolvedChildren: Array<{ parentId: string; child: SessionLike; childUpdatedAt: number }> = [];
|
|
303
|
+
|
|
304
|
+
// Enrich and nest child sessions under parents
|
|
305
|
+
for (const child of childSessions) {
|
|
306
|
+
if (child.time?.archived) continue;
|
|
307
|
+
|
|
308
|
+
// Find parent by parentID
|
|
309
|
+
let parent = child.parentID
|
|
310
|
+
? enrichedSessions.find((s) => s.id === child.parentID)
|
|
311
|
+
: null;
|
|
312
|
+
|
|
313
|
+
if (!parent) {
|
|
314
|
+
const candidates = enrichedSessions
|
|
315
|
+
.filter((s) => s.directory === child.directory)
|
|
316
|
+
.sort((a, b) => getUpdatedAt(b) - getUpdatedAt(a));
|
|
317
|
+
|
|
318
|
+
parent =
|
|
319
|
+
candidates.find((s) => s.realTimeStatus === 'busy' || s.realTimeStatus === 'retry') ||
|
|
320
|
+
candidates[0];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (!parent) {
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const statusFromMap = statusMap[child.id]?.type;
|
|
328
|
+
const childUpdatedAt = getUpdatedAt(child);
|
|
329
|
+
const isRecent = childUpdatedAt > 0 && now - childUpdatedAt <= CHILD_ACTIVE_WINDOW_MS;
|
|
330
|
+
|
|
331
|
+
if (statusFromMap && statusFromMap !== 'idle') {
|
|
332
|
+
parent.children.push(toChildEntry(child, statusFromMap));
|
|
333
|
+
} else if (isRecent) {
|
|
334
|
+
if (unresolvedChildren.length < CHILD_STATUS_MESSAGE_CHECK_LIMIT) {
|
|
335
|
+
unresolvedChildren.push({ parentId: parent.id, child, childUpdatedAt });
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (unresolvedChildren.length > 0) {
|
|
343
|
+
const unresolvedChecks = await Promise.allSettled(
|
|
344
|
+
unresolvedChildren.map(async ({ parentId, child, childUpdatedAt }) => {
|
|
345
|
+
const port = sessionPortMap[child.id] ?? sessionPortMap[parentId];
|
|
346
|
+
const client = port ? clientByPort[port] : undefined;
|
|
347
|
+
const assumeBusyForUnknown =
|
|
348
|
+
childUpdatedAt > 0 && now - childUpdatedAt <= CHILD_UNKNOWN_STATE_BUSY_WINDOW_MS;
|
|
349
|
+
if (!client) {
|
|
350
|
+
return {
|
|
351
|
+
parentId,
|
|
352
|
+
child,
|
|
353
|
+
childStatus: assumeBusyForUnknown ? 'busy' as const : 'idle' as const,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
const partStatuses = await fetchPartStatuses(client, child.id);
|
|
359
|
+
const hasRunningState = partStatuses.some((status) => status === 'running');
|
|
360
|
+
const hasWaitingState = !hasRunningState && partStatuses.some(isWaitingPartStatus);
|
|
361
|
+
const hasActiveState = hasWaitingState || hasRunningState;
|
|
362
|
+
const recentlyActive = childUpdatedAt > 0 && now - childUpdatedAt <= 5 * 60 * 1000;
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
parentId,
|
|
366
|
+
child,
|
|
367
|
+
childWaitingForUser: hasWaitingState,
|
|
368
|
+
childStatus: hasActiveState
|
|
369
|
+
? 'busy' as const
|
|
370
|
+
: recentlyActive || assumeBusyForUnknown
|
|
371
|
+
? 'busy' as const
|
|
372
|
+
: 'idle' as const,
|
|
373
|
+
};
|
|
374
|
+
} catch {
|
|
375
|
+
return {
|
|
376
|
+
parentId,
|
|
377
|
+
child,
|
|
378
|
+
childWaitingForUser: false,
|
|
379
|
+
childStatus: assumeBusyForUnknown ? 'busy' as const : 'idle' as const,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
})
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
for (const check of unresolvedChecks) {
|
|
386
|
+
if (check.status !== 'fulfilled') continue;
|
|
387
|
+
if (check.value.childStatus === 'idle') continue;
|
|
388
|
+
const parent = parentById.get(check.value.parentId);
|
|
389
|
+
if (!parent) continue;
|
|
390
|
+
parent.children.push(toChildEntry(check.value.child, check.value.childStatus, check.value.childWaitingForUser));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const parentStatusFallbackCandidates = enrichedSessions
|
|
395
|
+
.filter((session) => {
|
|
396
|
+
if (session.realTimeStatus !== 'idle') return false;
|
|
397
|
+
if (session.time?.archived) return false;
|
|
398
|
+
const updatedAt = getUpdatedAt(session);
|
|
399
|
+
return updatedAt > 0 && now - updatedAt <= CHILD_ACTIVE_WINDOW_MS;
|
|
400
|
+
})
|
|
401
|
+
.sort((a, b) => getUpdatedAt(b) - getUpdatedAt(a))
|
|
402
|
+
.slice(0, CHILD_STATUS_MESSAGE_CHECK_LIMIT);
|
|
403
|
+
|
|
404
|
+
if (parentStatusFallbackCandidates.length > 0) {
|
|
405
|
+
const parentFallbackChecks = await Promise.allSettled(
|
|
406
|
+
parentStatusFallbackCandidates.map(async (session) => {
|
|
407
|
+
const updatedAt = getUpdatedAt(session);
|
|
408
|
+
const assumeBusyForUnknown =
|
|
409
|
+
updatedAt > 0 && now - updatedAt <= CHILD_UNKNOWN_STATE_BUSY_WINDOW_MS;
|
|
410
|
+
const port = sessionPortMap[session.id];
|
|
411
|
+
const client = port ? clientByPort[port] : undefined;
|
|
412
|
+
|
|
413
|
+
if (!client) {
|
|
414
|
+
return {
|
|
415
|
+
sessionId: session.id,
|
|
416
|
+
status: assumeBusyForUnknown ? 'busy' as const : 'idle' as const,
|
|
417
|
+
waitingForUser: false,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
const partStatuses = await fetchPartStatuses(client, session.id);
|
|
423
|
+
const hasRunningState = partStatuses.some((status) => status === 'running');
|
|
424
|
+
const hasWaitingState = !hasRunningState && partStatuses.some(isWaitingPartStatus);
|
|
425
|
+
const hasCompletedState =
|
|
426
|
+
partStatuses.length > 0 && partStatuses.every((status) => status === 'completed');
|
|
427
|
+
const recentlyActive = hasRecentActivity(session, now);
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
sessionId: session.id,
|
|
431
|
+
status: hasRunningState || hasWaitingState
|
|
432
|
+
? 'busy' as const
|
|
433
|
+
: hasCompletedState && !recentlyActive
|
|
434
|
+
? 'idle' as const
|
|
435
|
+
: assumeBusyForUnknown || recentlyActive
|
|
436
|
+
? 'busy' as const
|
|
437
|
+
: 'idle' as const,
|
|
438
|
+
waitingForUser: hasWaitingState,
|
|
439
|
+
};
|
|
440
|
+
} catch {
|
|
441
|
+
return {
|
|
442
|
+
sessionId: session.id,
|
|
443
|
+
status: assumeBusyForUnknown ? 'busy' as const : 'idle' as const,
|
|
444
|
+
waitingForUser: false,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
})
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
for (const check of parentFallbackChecks) {
|
|
451
|
+
if (check.status !== 'fulfilled') continue;
|
|
452
|
+
if (check.value.status === 'idle') continue;
|
|
453
|
+
const session = parentById.get(check.value.sessionId);
|
|
454
|
+
if (!session) continue;
|
|
455
|
+
session.realTimeStatus = check.value.status;
|
|
456
|
+
if (check.value.waitingForUser) {
|
|
457
|
+
session.waitingForUser = true;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Sort children for each parent: active first, then by updated time
|
|
463
|
+
for (const session of enrichedSessions) {
|
|
464
|
+
if (session.children.length > 0) {
|
|
465
|
+
session.children.sort((a, b) => {
|
|
466
|
+
const aActive = a.realTimeStatus === 'busy' || a.realTimeStatus === 'retry';
|
|
467
|
+
const bActive = b.realTimeStatus === 'busy' || b.realTimeStatus === 'retry';
|
|
468
|
+
|
|
469
|
+
if (aActive && !bActive) return -1;
|
|
470
|
+
if (!aActive && bActive) return 1;
|
|
471
|
+
|
|
472
|
+
// Both active or both idle: sort by update time (newest first)
|
|
473
|
+
const aTime = a.time?.updated || a.time?.created || 0;
|
|
474
|
+
const bTime = b.time?.updated || b.time?.created || 0;
|
|
475
|
+
return bTime - aTime;
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const sessionsForInteractionChecks = enrichedSessions.filter(
|
|
481
|
+
(s) =>
|
|
482
|
+
s.realTimeStatus === 'busy' ||
|
|
483
|
+
s.children.some((child) => child.realTimeStatus === 'busy' || child.realTimeStatus === 'retry')
|
|
484
|
+
);
|
|
485
|
+
if (sessionsForInteractionChecks.length > 0) {
|
|
486
|
+
const pendingChecks = await Promise.allSettled(
|
|
487
|
+
sessionsForInteractionChecks.map(async (session) => {
|
|
488
|
+
const port = sessionPortMap[session.id];
|
|
489
|
+
const client = port ? clientByPort[port] : undefined;
|
|
490
|
+
if (!client) {
|
|
491
|
+
return { sessionId: session.id, waiting: false, waitingChildIds: new Set<string>() };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
try {
|
|
495
|
+
const partStatuses = await fetchPartStatuses(client, session.id);
|
|
496
|
+
const hasRunning = partStatuses.some((status) => status === 'running');
|
|
497
|
+
const hasInteractionWait = !hasRunning && partStatuses.some(isWaitingPartStatus);
|
|
498
|
+
|
|
499
|
+
const childStateChecks = await Promise.allSettled(
|
|
500
|
+
session.children
|
|
501
|
+
.filter((child) => child.realTimeStatus === 'busy' || child.realTimeStatus === 'retry')
|
|
502
|
+
.map(async (child) => {
|
|
503
|
+
const childPort = sessionPortMap[child.id] ?? sessionPortMap[session.id];
|
|
504
|
+
const childClient = childPort ? clientByPort[childPort] : undefined;
|
|
505
|
+
if (!childClient) {
|
|
506
|
+
return { childId: child.id, waiting: false };
|
|
507
|
+
}
|
|
508
|
+
try {
|
|
509
|
+
const childStatuses = await fetchPartStatuses(childClient, child.id);
|
|
510
|
+
const childHasRunning = childStatuses.some((status) => status === 'running');
|
|
511
|
+
return {
|
|
512
|
+
childId: child.id,
|
|
513
|
+
waiting: !childHasRunning && childStatuses.some(isWaitingPartStatus),
|
|
514
|
+
};
|
|
515
|
+
} catch {
|
|
516
|
+
return { childId: child.id, waiting: false };
|
|
517
|
+
}
|
|
518
|
+
})
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
const waitingChildIds = new Set(
|
|
522
|
+
childStateChecks
|
|
523
|
+
.filter((result): result is PromiseFulfilledResult<{ childId: string; waiting: boolean }> => result.status === 'fulfilled')
|
|
524
|
+
.filter((result) => result.value.waiting)
|
|
525
|
+
.map((result) => result.value.childId)
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
const hasWaitingChildren =
|
|
529
|
+
waitingChildIds.size > 0 ||
|
|
530
|
+
session.children.some((child) => child.waitingForUser || child.realTimeStatus === 'retry');
|
|
531
|
+
|
|
532
|
+
return {
|
|
533
|
+
sessionId: session.id,
|
|
534
|
+
waiting: hasInteractionWait || hasWaitingChildren,
|
|
535
|
+
waitingChildIds,
|
|
536
|
+
};
|
|
537
|
+
} catch {
|
|
538
|
+
return { sessionId: session.id, waiting: false, waitingChildIds: new Set<string>() };
|
|
539
|
+
}
|
|
540
|
+
})
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
for (const result of pendingChecks) {
|
|
544
|
+
if (result.status === 'fulfilled') {
|
|
545
|
+
const session = enrichedSessions.find((s) => s.id === result.value.sessionId);
|
|
546
|
+
if (!session) continue;
|
|
547
|
+
for (const child of session.children) {
|
|
548
|
+
if (result.value.waitingChildIds.has(child.id)) {
|
|
549
|
+
child.waitingForUser = true;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (result.value.waiting) {
|
|
553
|
+
session.waitingForUser = true;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const stickyNow = Date.now();
|
|
560
|
+
for (const session of enrichedSessions) {
|
|
561
|
+
for (const child of session.children) {
|
|
562
|
+
const normalizedChildStatus = normalizeRealtimeStatus(child.realTimeStatus);
|
|
563
|
+
child.realTimeStatus = applyStickyBusyStatus(`child:${child.id}`, normalizedChildStatus, stickyNow);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const normalizedSessionStatus = normalizeRealtimeStatus(session.realTimeStatus);
|
|
567
|
+
const sessionStatusForStabilization =
|
|
568
|
+
session.waitingForUser && normalizedSessionStatus === 'idle' ? 'busy' : normalizedSessionStatus;
|
|
569
|
+
session.realTimeStatus = applyStickyBusyStatus(session.id, sessionStatusForStabilization, stickyNow);
|
|
570
|
+
}
|
|
571
|
+
pruneStickyState(stickyNow);
|
|
572
|
+
|
|
573
|
+
const knownDirectories = new Set<string>();
|
|
574
|
+
for (const session of sessions) {
|
|
575
|
+
if (session.directory) {
|
|
576
|
+
knownDirectories.add(session.directory);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const processHints = Array.from(processHintsByDirectory.values()).filter(
|
|
581
|
+
(hint) => !knownDirectories.has(hint.directory)
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
return Response.json({ sessions: enrichedSessions, processHints });
|
|
585
|
+
} catch (error) {
|
|
586
|
+
console.error('Error fetching sessions:', error);
|
|
587
|
+
return Response.json(
|
|
588
|
+
{
|
|
589
|
+
error: 'Failed to fetch sessions',
|
|
590
|
+
details: error instanceof Error ? error.message : String(error),
|
|
591
|
+
hint: 'Make sure OpenCode is running with an exposed API port. Example: opencode --port <PORT> (VibePulse auto-detects active ports).'
|
|
592
|
+
},
|
|
593
|
+
{ status: 500 }
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
:root {
|
|
4
|
+
--background: #ffffff;
|
|
5
|
+
--foreground: #171717;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
@theme inline {
|
|
9
|
+
--color-background: var(--background);
|
|
10
|
+
--color-foreground: var(--foreground);
|
|
11
|
+
--font-sans: var(--font-geist-sans);
|
|
12
|
+
--font-mono: var(--font-geist-mono);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@media (prefers-color-scheme: dark) {
|
|
16
|
+
:root {
|
|
17
|
+
--background: #0a0a0a;
|
|
18
|
+
--foreground: #ededed;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
body {
|
|
23
|
+
background: var(--background);
|
|
24
|
+
color: var(--foreground);
|
|
25
|
+
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* Custom scrollbar styling */
|
|
29
|
+
.scrollbar-thin::-webkit-scrollbar {
|
|
30
|
+
width: 6px;
|
|
31
|
+
height: 6px;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.scrollbar-thin::-webkit-scrollbar-track {
|
|
35
|
+
background: transparent;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.scrollbar-thin::-webkit-scrollbar-thumb {
|
|
39
|
+
background-color: rgba(156, 163, 175, 0.5);
|
|
40
|
+
border-radius: 20px;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
|
44
|
+
background-color: rgba(156, 163, 175, 0.7);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* Firefox scrollbar */
|
|
48
|
+
.scrollbar-thin {
|
|
49
|
+
scrollbar-width: thin;
|
|
50
|
+
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* Dark mode scrollbar */
|
|
54
|
+
@media (prefers-color-scheme: dark) {
|
|
55
|
+
.scrollbar-thin::-webkit-scrollbar-thumb {
|
|
56
|
+
background-color: rgba(75, 85, 99, 0.5);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
|
60
|
+
background-color: rgba(75, 85, 99, 0.7);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.scrollbar-thin {
|
|
64
|
+
scrollbar-color: rgba(75, 85, 99, 0.5) transparent;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { Geist, Geist_Mono } from "next/font/google";
|
|
3
|
+
import "./globals.css";
|
|
4
|
+
import { QueryProvider } from "@/components/QueryProvider";
|
|
5
|
+
|
|
6
|
+
const geistSans = Geist({
|
|
7
|
+
variable: "--font-geist-sans",
|
|
8
|
+
subsets: ["latin"],
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const geistMono = Geist_Mono({
|
|
12
|
+
variable: "--font-geist-mono",
|
|
13
|
+
subsets: ["latin"],
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const metadata: Metadata = {
|
|
17
|
+
title: "VibePulse",
|
|
18
|
+
description: "Real-time dashboard for monitoring OpenCode sessions",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default function RootLayout({
|
|
22
|
+
children,
|
|
23
|
+
}: Readonly<{
|
|
24
|
+
children: React.ReactNode;
|
|
25
|
+
}>) {
|
|
26
|
+
return (
|
|
27
|
+
<html lang="en">
|
|
28
|
+
<body
|
|
29
|
+
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
30
|
+
>
|
|
31
|
+
<QueryProvider>
|
|
32
|
+
{children}
|
|
33
|
+
</QueryProvider>
|
|
34
|
+
</body>
|
|
35
|
+
</html>
|
|
36
|
+
);
|
|
37
|
+
}
|