cohvu 0.6.2 → 1.0.0
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/dist/api.d.ts +128 -0
- package/dist/api.js +221 -0
- package/dist/api.js.map +1 -0
- package/dist/auth.js +2 -2
- package/dist/auth.js.map +1 -1
- package/dist/index.js +83 -825
- package/dist/index.js.map +1 -1
- package/dist/proxy.d.ts +1 -0
- package/dist/proxy.js +151 -0
- package/dist/proxy.js.map +1 -0
- package/dist/setup.js +1 -1
- package/dist/setup.js.map +1 -1
- package/dist/tui/ansi.d.ts +41 -0
- package/dist/tui/ansi.js +120 -0
- package/dist/tui/ansi.js.map +1 -0
- package/dist/tui/dashboard.d.ts +1 -0
- package/dist/tui/dashboard.js +1051 -0
- package/dist/tui/dashboard.js.map +1 -0
- package/dist/tui/keys.d.ts +31 -0
- package/dist/tui/keys.js +53 -0
- package/dist/tui/keys.js.map +1 -0
- package/dist/tui/platform-detect.d.ts +2 -0
- package/dist/tui/platform-detect.js +102 -0
- package/dist/tui/platform-detect.js.map +1 -0
- package/dist/tui/render.d.ts +5 -0
- package/dist/tui/render.js +139 -0
- package/dist/tui/render.js.map +1 -0
- package/dist/tui/state.d.ts +215 -0
- package/dist/tui/state.js +206 -0
- package/dist/tui/state.js.map +1 -0
- package/dist/tui/views/billing.d.ts +3 -0
- package/dist/tui/views/billing.js +127 -0
- package/dist/tui/views/billing.js.map +1 -0
- package/dist/tui/views/knowledge.d.ts +3 -0
- package/dist/tui/views/knowledge.js +195 -0
- package/dist/tui/views/knowledge.js.map +1 -0
- package/dist/tui/views/modals.d.ts +3 -0
- package/dist/tui/views/modals.js +192 -0
- package/dist/tui/views/modals.js.map +1 -0
- package/dist/tui/views/project.d.ts +3 -0
- package/dist/tui/views/project.js +76 -0
- package/dist/tui/views/project.js.map +1 -0
- package/dist/tui/views/team.d.ts +3 -0
- package/dist/tui/views/team.js +100 -0
- package/dist/tui/views/team.js.map +1 -0
- package/dist/tui/views/you.d.ts +3 -0
- package/dist/tui/views/you.js +74 -0
- package/dist/tui/views/you.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1051 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Dashboard main loop — raw mode, keypress dispatch, render, SSE, fs.watch.
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.launchDashboard = launchDashboard;
|
|
5
|
+
const keys_1 = require("./keys");
|
|
6
|
+
const state_1 = require("./state");
|
|
7
|
+
const render_1 = require("./render");
|
|
8
|
+
const ansi_1 = require("./ansi");
|
|
9
|
+
const api_1 = require("../api");
|
|
10
|
+
const platform_detect_1 = require("./platform-detect");
|
|
11
|
+
const child_process_1 = require("child_process");
|
|
12
|
+
const fs_1 = require("fs");
|
|
13
|
+
const path_1 = require("path");
|
|
14
|
+
const os_1 = require("os");
|
|
15
|
+
const STATE_FILE = (0, path_1.join)((0, os_1.homedir)(), ".cohvu", "state.json");
|
|
16
|
+
const PAGE_SIZE = 20;
|
|
17
|
+
async function launchDashboard() {
|
|
18
|
+
const api = new api_1.ApiClient();
|
|
19
|
+
let state = (0, state_1.initialState)();
|
|
20
|
+
const watchers = [];
|
|
21
|
+
let searchTimer = null;
|
|
22
|
+
let liveDotTimer = null;
|
|
23
|
+
let feedDisconnect = null;
|
|
24
|
+
let copiedTimer = null;
|
|
25
|
+
let inlineErrorTimer = null;
|
|
26
|
+
// Load first-login state
|
|
27
|
+
const savedState = loadState();
|
|
28
|
+
state.firstLogin = !savedState.hasOpenedDashboard;
|
|
29
|
+
// Dispatch helper
|
|
30
|
+
function dispatch(action) {
|
|
31
|
+
state = (0, state_1.reduce)(state, action);
|
|
32
|
+
// Regenerate banners on relevant state changes
|
|
33
|
+
if (['SET_USER_DATA', 'SET_PLATFORMS', 'SET_BILLING', 'PLATFORM_CONFIGURED', 'SET_PENDING_APPROVALS', 'SET_NOTIFICATIONS'].includes(action.type)) {
|
|
34
|
+
state = { ...state, bannerLines: generateBanners(state) };
|
|
35
|
+
}
|
|
36
|
+
doRender();
|
|
37
|
+
}
|
|
38
|
+
function doRender() {
|
|
39
|
+
const dims = { rows: process.stdout.rows, cols: process.stdout.columns };
|
|
40
|
+
(0, render_1.render)(state, dims);
|
|
41
|
+
}
|
|
42
|
+
// Clean exit
|
|
43
|
+
function cleanup() {
|
|
44
|
+
if (feedDisconnect)
|
|
45
|
+
feedDisconnect();
|
|
46
|
+
watchers.forEach(w => w.close());
|
|
47
|
+
if (searchTimer)
|
|
48
|
+
clearTimeout(searchTimer);
|
|
49
|
+
if (liveDotTimer)
|
|
50
|
+
clearTimeout(liveDotTimer);
|
|
51
|
+
if (inlineErrorTimer)
|
|
52
|
+
clearTimeout(inlineErrorTimer);
|
|
53
|
+
process.stdin.setRawMode?.(false);
|
|
54
|
+
(0, ansi_1.exitScreen)();
|
|
55
|
+
}
|
|
56
|
+
function cleanExit() {
|
|
57
|
+
cleanup();
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
process.on('SIGTERM', cleanExit);
|
|
61
|
+
process.on('SIGINT', cleanExit);
|
|
62
|
+
process.on('uncaughtException', (err) => {
|
|
63
|
+
cleanup();
|
|
64
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
});
|
|
67
|
+
// Enter TUI
|
|
68
|
+
(0, ansi_1.enterScreen)();
|
|
69
|
+
process.stdin.setRawMode?.(true);
|
|
70
|
+
process.stdin.resume();
|
|
71
|
+
// Resize handler
|
|
72
|
+
process.stdout.on('resize', doRender);
|
|
73
|
+
// Initial render (loading state)
|
|
74
|
+
doRender();
|
|
75
|
+
// Load data
|
|
76
|
+
try {
|
|
77
|
+
const me = await api.me();
|
|
78
|
+
dispatch({ type: 'SET_USER_DATA', me });
|
|
79
|
+
// Detect platforms
|
|
80
|
+
const platforms = (0, platform_detect_1.detectPlatformStatuses)();
|
|
81
|
+
dispatch({ type: 'SET_PLATFORMS', platforms });
|
|
82
|
+
// Load knowledge, members, billing, invite links, notifications in parallel
|
|
83
|
+
const projectId = state.activeProjectId;
|
|
84
|
+
if (projectId) {
|
|
85
|
+
const [memResult, members, billing, inviteLinks, notifications] = await Promise.all([
|
|
86
|
+
api.listMemories(projectId, { limit: PAGE_SIZE, offset: 0 }).catch(() => null),
|
|
87
|
+
api.listMembers(projectId).catch(() => []),
|
|
88
|
+
api.getBilling(projectId).catch(() => null),
|
|
89
|
+
api.listInviteLinks(projectId).catch(() => []),
|
|
90
|
+
api.listNotifications().catch(() => []),
|
|
91
|
+
]);
|
|
92
|
+
if (memResult) {
|
|
93
|
+
dispatch({ type: 'SET_MEMORIES', memories: memResult.memories, total: memResult.total });
|
|
94
|
+
}
|
|
95
|
+
dispatch({ type: 'SET_MEMBERS', members });
|
|
96
|
+
if (billing) {
|
|
97
|
+
dispatch({ type: 'SET_BILLING', billing });
|
|
98
|
+
}
|
|
99
|
+
dispatch({ type: 'SET_INVITE_LINKS', links: inviteLinks });
|
|
100
|
+
dispatch({ type: 'SET_NOTIFICATIONS', notifications });
|
|
101
|
+
// Mark notifications as seen
|
|
102
|
+
if (notifications.length > 0) {
|
|
103
|
+
api.markNotificationsSeen().catch(() => { });
|
|
104
|
+
}
|
|
105
|
+
// Connect SSE feed
|
|
106
|
+
connectFeed(projectId);
|
|
107
|
+
}
|
|
108
|
+
// Mark first login seen
|
|
109
|
+
if (state.firstLogin) {
|
|
110
|
+
saveState({ hasOpenedDashboard: true });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
dispatch({ type: 'SET_OFFLINE', offline: true });
|
|
115
|
+
dispatch({ type: 'SET_ERROR', error: "can't reach cohvu" });
|
|
116
|
+
}
|
|
117
|
+
// Watch plugin config files
|
|
118
|
+
setupFileWatchers();
|
|
119
|
+
// Keypress loop
|
|
120
|
+
process.stdin.on('data', async (data) => {
|
|
121
|
+
const key = (0, keys_1.parseKey)(data);
|
|
122
|
+
await handleKey(key);
|
|
123
|
+
doRender();
|
|
124
|
+
});
|
|
125
|
+
// ------ Key handler ------
|
|
126
|
+
async function handleKey(key) {
|
|
127
|
+
// Global keys
|
|
128
|
+
if (key.name === 'ctrl-c' || (key.name === 'char' && key.char === 'q' && !state.modal && state.knowledgeMode !== 'search')) {
|
|
129
|
+
cleanExit();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// Modal keys
|
|
133
|
+
if (state.modal) {
|
|
134
|
+
await handleModalKey(key);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
// Tab switching
|
|
138
|
+
if (key.name === 'tab') {
|
|
139
|
+
dispatch({ type: 'NEXT_TAB' });
|
|
140
|
+
await loadTabData();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (key.name === 'char') {
|
|
144
|
+
const tabIdx = parseInt(key.char, 10);
|
|
145
|
+
if (tabIdx >= 1 && tabIdx <= 5) {
|
|
146
|
+
dispatch({ type: 'SWITCH_TAB', tab: state_1.TABS[tabIdx - 1] });
|
|
147
|
+
await loadTabData();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Tab-specific keys
|
|
152
|
+
switch (state.tab) {
|
|
153
|
+
case 'knowledge':
|
|
154
|
+
await handleKnowledgeKey(key);
|
|
155
|
+
break;
|
|
156
|
+
case 'team':
|
|
157
|
+
await handleTeamKey(key);
|
|
158
|
+
break;
|
|
159
|
+
case 'billing':
|
|
160
|
+
await handleBillingKey(key);
|
|
161
|
+
break;
|
|
162
|
+
case 'project':
|
|
163
|
+
await handleProjectKey(key);
|
|
164
|
+
break;
|
|
165
|
+
case 'you':
|
|
166
|
+
await handleYouKey(key);
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// ------ Knowledge keys ------
|
|
171
|
+
async function handleKnowledgeKey(key) {
|
|
172
|
+
// alt+d enters forget mode from any knowledge mode
|
|
173
|
+
if (key.name === 'alt-d' && state.userRole !== 'viewer') {
|
|
174
|
+
const list = state.searchResults ?? state.memories;
|
|
175
|
+
if (list.length > 0) {
|
|
176
|
+
dispatch({ type: 'ENTER_FORGET' });
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (state.knowledgeMode === 'search') {
|
|
181
|
+
if (key.name === 'escape') {
|
|
182
|
+
dispatch({ type: 'EXIT_SEARCH' });
|
|
183
|
+
}
|
|
184
|
+
else if (key.name === 'enter' && state.searchQuery.length > 0) {
|
|
185
|
+
// Enter triggers the search
|
|
186
|
+
await executeSearch();
|
|
187
|
+
}
|
|
188
|
+
else if (key.name === 'backspace') {
|
|
189
|
+
dispatch({ type: 'SEARCH_BACKSPACE' });
|
|
190
|
+
}
|
|
191
|
+
else if (key.name === 'char') {
|
|
192
|
+
dispatch({ type: 'SEARCH_INPUT', char: key.char });
|
|
193
|
+
}
|
|
194
|
+
else if (key.name === 'up') {
|
|
195
|
+
dispatch({ type: 'SCROLL_UP' });
|
|
196
|
+
}
|
|
197
|
+
else if (key.name === 'down') {
|
|
198
|
+
dispatch({ type: 'SCROLL_DOWN' });
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (state.knowledgeMode === 'forget') {
|
|
203
|
+
if (key.name === 'escape') {
|
|
204
|
+
dispatch({ type: 'EXIT_FORGET' });
|
|
205
|
+
}
|
|
206
|
+
else if (key.name === 'space') {
|
|
207
|
+
const list = state.memories;
|
|
208
|
+
const mem = list[state.memorySelected];
|
|
209
|
+
if (mem) {
|
|
210
|
+
// Members can only toggle their own contributions
|
|
211
|
+
const isOwn = state.userRole === 'admin' || (mem.contributed_by?.user_id === state.user?.id);
|
|
212
|
+
if (isOwn) {
|
|
213
|
+
dispatch({ type: 'TOGGLE_FORGET', memoryId: mem.id });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
dispatch({ type: 'SCROLL_DOWN' });
|
|
217
|
+
}
|
|
218
|
+
else if (key.name === 'up') {
|
|
219
|
+
dispatch({ type: 'SCROLL_UP' });
|
|
220
|
+
}
|
|
221
|
+
else if (key.name === 'down') {
|
|
222
|
+
dispatch({ type: 'SCROLL_DOWN' });
|
|
223
|
+
}
|
|
224
|
+
else if (key.name === 'enter' && state.forgetSelected.size > 0) {
|
|
225
|
+
// Batch delete
|
|
226
|
+
const projectId = state.activeProjectId;
|
|
227
|
+
if (projectId) {
|
|
228
|
+
await Promise.all([...state.forgetSelected].map(async (id) => {
|
|
229
|
+
try {
|
|
230
|
+
await api.deleteMemory(projectId, id);
|
|
231
|
+
dispatch({ type: 'REMOVE_MEMORY', id });
|
|
232
|
+
}
|
|
233
|
+
catch { }
|
|
234
|
+
}));
|
|
235
|
+
dispatch({ type: 'EXIT_FORGET' });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
// Browse mode
|
|
241
|
+
if (key.name === 'char' && key.char === '/') {
|
|
242
|
+
dispatch({ type: 'ENTER_SEARCH' });
|
|
243
|
+
}
|
|
244
|
+
else if (key.name === 'char' && key.char === 'D' && state.userRole === 'admin') {
|
|
245
|
+
const project = (0, state_1.getActiveProject)(state);
|
|
246
|
+
if (project) {
|
|
247
|
+
dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-forget-all', slug: project.slug, memoryCount: state.memoryTotal, input: '' } });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
else if (key.name === 'up') {
|
|
251
|
+
dispatch({ type: 'SCROLL_UP' });
|
|
252
|
+
}
|
|
253
|
+
else if (key.name === 'down') {
|
|
254
|
+
dispatch({ type: 'SCROLL_DOWN' });
|
|
255
|
+
}
|
|
256
|
+
else if (key.name === 'space') {
|
|
257
|
+
await loadMoreMemories();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// ------ Team keys ------
|
|
261
|
+
function showInlineError(message, durationMs) {
|
|
262
|
+
dispatch({ type: 'SET_INLINE_ERROR', message });
|
|
263
|
+
if (inlineErrorTimer)
|
|
264
|
+
clearTimeout(inlineErrorTimer);
|
|
265
|
+
inlineErrorTimer = setTimeout(() => {
|
|
266
|
+
dispatch({ type: 'SET_INLINE_ERROR', message: null });
|
|
267
|
+
}, durationMs);
|
|
268
|
+
}
|
|
269
|
+
async function handleTeamKey(key) {
|
|
270
|
+
const memberCount = state.members.length;
|
|
271
|
+
const linkRoles = ['admin', 'member', 'viewer'];
|
|
272
|
+
const linkCount = state.userRole === 'admin' ? linkRoles.length : 0;
|
|
273
|
+
const totalRows = memberCount + linkCount;
|
|
274
|
+
const sel = state.teamSelected;
|
|
275
|
+
const onLinkRow = state.userRole === 'admin' && sel >= memberCount;
|
|
276
|
+
const onMemberRow = sel < memberCount;
|
|
277
|
+
// Up/down navigation
|
|
278
|
+
if (key.name === 'up') {
|
|
279
|
+
const newIdx = Math.max(0, sel - 1);
|
|
280
|
+
dispatch({ type: 'SET_TEAM_SELECTED', index: newIdx });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (key.name === 'down') {
|
|
284
|
+
const newIdx = Math.min(totalRows - 1, sel + 1);
|
|
285
|
+
dispatch({ type: 'SET_TEAM_SELECTED', index: newIdx });
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
// 'c' — copy invite link (only when cursor is on a link row, admin only)
|
|
289
|
+
if (key.name === 'char' && key.char === 'c' && state.userRole === 'admin' && onLinkRow) {
|
|
290
|
+
const linkIdx = sel - memberCount;
|
|
291
|
+
const role = linkRoles[linkIdx];
|
|
292
|
+
const link = state.inviteLinks.find(l => l.role === role);
|
|
293
|
+
if (link) {
|
|
294
|
+
copyToClipboard(link.url);
|
|
295
|
+
dispatch({ type: 'SET_COPIED_FEEDBACK', active: true });
|
|
296
|
+
if (copiedTimer)
|
|
297
|
+
clearTimeout(copiedTimer);
|
|
298
|
+
copiedTimer = setTimeout(() => {
|
|
299
|
+
dispatch({ type: 'SET_COPIED_FEEDBACK', active: false });
|
|
300
|
+
}, 1500);
|
|
301
|
+
}
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
// 'r' — regen invite link (only when cursor is on a link row, admin only)
|
|
305
|
+
if (key.name === 'char' && key.char === 'r' && state.userRole === 'admin' && onLinkRow) {
|
|
306
|
+
const linkIdx = sel - memberCount;
|
|
307
|
+
const role = linkRoles[linkIdx];
|
|
308
|
+
dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-regen-link', role } });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
// 'e' — edit role (only when cursor is on a member row, admin only)
|
|
312
|
+
if (key.name === 'char' && key.char === 'e' && state.userRole === 'admin' && onMemberRow) {
|
|
313
|
+
const target = state.members[sel];
|
|
314
|
+
if (target) {
|
|
315
|
+
// Cannot edit own role
|
|
316
|
+
if (target.user_id === state.user?.id) {
|
|
317
|
+
showInlineError('you cannot change your own role', 2000);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const roleIdx = ['admin', 'member', 'viewer'].indexOf(target.role ?? 'member');
|
|
321
|
+
dispatch({ type: 'OPEN_MODAL', modal: {
|
|
322
|
+
kind: 'edit-role',
|
|
323
|
+
targetEmail: target.email ?? target.user_id,
|
|
324
|
+
targetUserId: target.user_id,
|
|
325
|
+
currentRole: target.role ?? 'member',
|
|
326
|
+
selected: roleIdx >= 0 ? roleIdx : 1,
|
|
327
|
+
} });
|
|
328
|
+
}
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
// 'a' — approve pending approval (admin only)
|
|
332
|
+
if (key.name === 'char' && key.char === 'a' && state.userRole === 'admin') {
|
|
333
|
+
const demoteApproval = state.pendingApprovals.find(a => a.action === 'demote_admin');
|
|
334
|
+
if (demoteApproval) {
|
|
335
|
+
dispatch({ type: 'OPEN_MODAL', modal: {
|
|
336
|
+
kind: 'approve-action',
|
|
337
|
+
approvalId: demoteApproval.id,
|
|
338
|
+
description: demoteApproval.description,
|
|
339
|
+
initiator: demoteApproval.initiator_email,
|
|
340
|
+
expiresIn: '',
|
|
341
|
+
} });
|
|
342
|
+
}
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
// 'x' — cancel pending approval (admin, when approval exists) or remove member or leave
|
|
346
|
+
if (key.name === 'char' && key.char === 'x' && state.userRole === 'admin' && state.pendingApprovals.length > 0) {
|
|
347
|
+
const demoteApproval = state.pendingApprovals.find(a => a.action === 'demote_admin');
|
|
348
|
+
if (demoteApproval) {
|
|
349
|
+
const projectId = state.activeProjectId;
|
|
350
|
+
if (projectId) {
|
|
351
|
+
try {
|
|
352
|
+
await api.cancelApproval(projectId, demoteApproval.id);
|
|
353
|
+
const approvals = await api.listApprovals(projectId);
|
|
354
|
+
dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
|
|
355
|
+
}
|
|
356
|
+
catch { }
|
|
357
|
+
}
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// 'x' — remove member (admin on member row) or leave (member/viewer)
|
|
362
|
+
if (key.name === 'char' && key.char === 'x') {
|
|
363
|
+
if (state.userRole === 'admin' && onMemberRow) {
|
|
364
|
+
const target = state.members[sel];
|
|
365
|
+
if (target) {
|
|
366
|
+
if (target.user_id === state.user?.id) {
|
|
367
|
+
// Admin trying to leave — check if last admin
|
|
368
|
+
const adminCount = state.members.filter(m => m.role === 'admin').length;
|
|
369
|
+
if (adminCount <= 1) {
|
|
370
|
+
showInlineError('you are the only admin \u00b7 promote another member before leaving', 3000);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-leave' } });
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-remove-member', email: target.email ?? target.user_id, userId: target.user_id } });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
else if (state.userRole !== 'admin') {
|
|
381
|
+
// Member/viewer can leave
|
|
382
|
+
dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-leave' } });
|
|
383
|
+
}
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// ------ Billing keys ------
|
|
388
|
+
async function handleBillingKey(key) {
|
|
389
|
+
if (state.userRole !== 'admin')
|
|
390
|
+
return;
|
|
391
|
+
if (key.name === 'char' && key.char === 's') {
|
|
392
|
+
const projectId = state.activeProjectId;
|
|
393
|
+
if (projectId) {
|
|
394
|
+
try {
|
|
395
|
+
const checkout = await api.createCheckout(projectId);
|
|
396
|
+
if (checkout.checkout_url)
|
|
397
|
+
openBrowser(checkout.checkout_url);
|
|
398
|
+
}
|
|
399
|
+
catch { }
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
else if (key.name === 'char' && key.char === 'p') {
|
|
403
|
+
const projectId = state.activeProjectId;
|
|
404
|
+
if (projectId) {
|
|
405
|
+
try {
|
|
406
|
+
const portal = await api.getPortalUrl(projectId);
|
|
407
|
+
if (portal.url)
|
|
408
|
+
openBrowser(portal.url);
|
|
409
|
+
}
|
|
410
|
+
catch { }
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// ------ Project keys ------
|
|
415
|
+
async function handleProjectKey(key) {
|
|
416
|
+
// 'a' — approve pending approval (admin only)
|
|
417
|
+
if (key.name === 'char' && key.char === 'a' && state.userRole === 'admin') {
|
|
418
|
+
const projectApproval = state.pendingApprovals.find(a => a.action === 'delete_project' || a.action === 'clear_memories');
|
|
419
|
+
if (projectApproval) {
|
|
420
|
+
dispatch({ type: 'OPEN_MODAL', modal: {
|
|
421
|
+
kind: 'approve-action',
|
|
422
|
+
approvalId: projectApproval.id,
|
|
423
|
+
description: projectApproval.description,
|
|
424
|
+
initiator: projectApproval.initiator_email,
|
|
425
|
+
expiresIn: '',
|
|
426
|
+
} });
|
|
427
|
+
}
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
// 'x' — cancel pending approval (admin only)
|
|
431
|
+
if (key.name === 'char' && key.char === 'x' && state.userRole === 'admin') {
|
|
432
|
+
const projectApproval = state.pendingApprovals.find(a => a.action === 'delete_project' || a.action === 'clear_memories');
|
|
433
|
+
if (projectApproval) {
|
|
434
|
+
const projectId = state.activeProjectId;
|
|
435
|
+
if (projectId) {
|
|
436
|
+
try {
|
|
437
|
+
await api.cancelApproval(projectId, projectApproval.id);
|
|
438
|
+
const approvals = await api.listApprovals(projectId);
|
|
439
|
+
dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
|
|
440
|
+
}
|
|
441
|
+
catch { }
|
|
442
|
+
}
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (key.name === 'char' && key.char === 'r' && state.userRole === 'admin') {
|
|
447
|
+
dispatch({ type: 'OPEN_MODAL', modal: { kind: 'rename', input: '' } });
|
|
448
|
+
}
|
|
449
|
+
else if (key.name === 'char' && key.char === 'n') {
|
|
450
|
+
dispatch({ type: 'OPEN_MODAL', modal: { kind: 'create-project', input: '' } });
|
|
451
|
+
}
|
|
452
|
+
else if (key.name === 'char' && key.char === 'w' && state.projects.length > 1) {
|
|
453
|
+
dispatch({ type: 'OPEN_MODAL', modal: { kind: 'switch-project', selected: 0 } });
|
|
454
|
+
}
|
|
455
|
+
else if (key.name === 'char' && key.char === 'c' && state.userRole === 'admin') {
|
|
456
|
+
const project = (0, state_1.getActiveProject)(state);
|
|
457
|
+
if (project) {
|
|
458
|
+
const adminCount = state.members.filter(m => m.role === 'admin').length;
|
|
459
|
+
if (adminCount > 1) {
|
|
460
|
+
dispatch({ type: 'OPEN_MODAL', modal: { kind: 'initiate-consensus', action: 'clear_memories', description: 'clear all memories' } });
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-clear', slug: project.slug, memoryCount: state.memoryTotal, input: '' } });
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
else if (key.name === 'char' && key.char === 'd' && state.userRole === 'admin') {
|
|
468
|
+
const project = (0, state_1.getActiveProject)(state);
|
|
469
|
+
if (project) {
|
|
470
|
+
const adminCount = state.members.filter(m => m.role === 'admin').length;
|
|
471
|
+
if (adminCount > 1) {
|
|
472
|
+
dispatch({ type: 'OPEN_MODAL', modal: { kind: 'initiate-consensus', action: 'delete_project', description: 'delete ' + project.slug } });
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-delete', slug: project.slug, memoryCount: state.memoryTotal, input: '' } });
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
// ------ You keys ------
|
|
481
|
+
async function handleYouKey(key) {
|
|
482
|
+
if (key.name === 'char' && key.char === 'r') {
|
|
483
|
+
// Re-run setup
|
|
484
|
+
const platforms = (0, platform_detect_1.detectPlatformStatuses)();
|
|
485
|
+
dispatch({ type: 'SET_PLATFORMS', platforms });
|
|
486
|
+
}
|
|
487
|
+
else if (key.name === 'char' && key.char === 'l') {
|
|
488
|
+
dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-logout' } });
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
// ------ Modal keys ------
|
|
492
|
+
async function handleModalKey(key) {
|
|
493
|
+
if (!state.modal)
|
|
494
|
+
return;
|
|
495
|
+
if (key.name === 'escape') {
|
|
496
|
+
dispatch({ type: 'CLOSE_MODAL' });
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const modal = state.modal;
|
|
500
|
+
// y/n modals
|
|
501
|
+
if (modal.kind === 'confirm-forget' || modal.kind === 'confirm-remove-member' || modal.kind === 'confirm-leave' || modal.kind === 'confirm-logout') {
|
|
502
|
+
if (key.name === 'char' && key.char === 'y') {
|
|
503
|
+
await confirmModal(modal);
|
|
504
|
+
dispatch({ type: 'CLOSE_MODAL' });
|
|
505
|
+
}
|
|
506
|
+
else if (key.name === 'char' && key.char === 'n') {
|
|
507
|
+
dispatch({ type: 'CLOSE_MODAL' });
|
|
508
|
+
}
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
// Initiate consensus modal (y/n)
|
|
512
|
+
if (modal.kind === 'initiate-consensus') {
|
|
513
|
+
if (key.name === 'char' && key.char === 'y') {
|
|
514
|
+
await confirmModal(modal);
|
|
515
|
+
dispatch({ type: 'CLOSE_MODAL' });
|
|
516
|
+
}
|
|
517
|
+
else if (key.name === 'char' && key.char === 'n') {
|
|
518
|
+
dispatch({ type: 'CLOSE_MODAL' });
|
|
519
|
+
}
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
// Approve action modal (y/n)
|
|
523
|
+
if (modal.kind === 'approve-action') {
|
|
524
|
+
if (key.name === 'char' && key.char === 'y') {
|
|
525
|
+
await confirmModal(modal);
|
|
526
|
+
dispatch({ type: 'CLOSE_MODAL' });
|
|
527
|
+
}
|
|
528
|
+
else if (key.name === 'char' && key.char === 'n') {
|
|
529
|
+
dispatch({ type: 'CLOSE_MODAL' });
|
|
530
|
+
}
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
// Confirm regen link modal (y/n)
|
|
534
|
+
if (modal.kind === 'confirm-regen-link') {
|
|
535
|
+
if (key.name === 'char' && key.char === 'y') {
|
|
536
|
+
await confirmModal(modal);
|
|
537
|
+
dispatch({ type: 'CLOSE_MODAL' });
|
|
538
|
+
}
|
|
539
|
+
else if (key.name === 'char' && key.char === 'n') {
|
|
540
|
+
dispatch({ type: 'CLOSE_MODAL' });
|
|
541
|
+
}
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
// Text input modals
|
|
545
|
+
if ('input' in modal) {
|
|
546
|
+
if (key.name === 'enter') {
|
|
547
|
+
await confirmModal(modal);
|
|
548
|
+
dispatch({ type: 'CLOSE_MODAL' });
|
|
549
|
+
}
|
|
550
|
+
else if (key.name === 'backspace') {
|
|
551
|
+
dispatch({ type: 'MODAL_BACKSPACE' });
|
|
552
|
+
}
|
|
553
|
+
else if (key.name === 'char') {
|
|
554
|
+
dispatch({ type: 'MODAL_INPUT', char: key.char });
|
|
555
|
+
}
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
// Switch project modal
|
|
559
|
+
if (modal.kind === 'switch-project') {
|
|
560
|
+
if (key.name === 'up') {
|
|
561
|
+
const newIdx = Math.max(0, modal.selected - 1);
|
|
562
|
+
dispatch({ type: 'OPEN_MODAL', modal: { kind: 'switch-project', selected: newIdx } });
|
|
563
|
+
}
|
|
564
|
+
else if (key.name === 'down') {
|
|
565
|
+
const newIdx = Math.min(state.projects.length - 1, modal.selected + 1);
|
|
566
|
+
dispatch({ type: 'OPEN_MODAL', modal: { kind: 'switch-project', selected: newIdx } });
|
|
567
|
+
}
|
|
568
|
+
else if (key.name === 'enter') {
|
|
569
|
+
const project = state.projects[modal.selected];
|
|
570
|
+
if (project) {
|
|
571
|
+
await api.switchProject(project.project_id);
|
|
572
|
+
const me = await api.me();
|
|
573
|
+
dispatch({ type: 'SET_USER_DATA', me });
|
|
574
|
+
dispatch({ type: 'CLOSE_MODAL' });
|
|
575
|
+
// Reconnect SSE and reload data for new project
|
|
576
|
+
if (state.activeProjectId)
|
|
577
|
+
connectFeed(state.activeProjectId);
|
|
578
|
+
await loadTabData();
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
// Edit role modal
|
|
583
|
+
if (modal.kind === 'edit-role') {
|
|
584
|
+
const roles = ['admin', 'member', 'viewer'];
|
|
585
|
+
if (key.name === 'up') {
|
|
586
|
+
const newIdx = Math.max(0, modal.selected - 1);
|
|
587
|
+
dispatch({ type: 'OPEN_MODAL', modal: { ...modal, selected: newIdx } });
|
|
588
|
+
}
|
|
589
|
+
else if (key.name === 'down') {
|
|
590
|
+
const newIdx = Math.min(roles.length - 1, modal.selected + 1);
|
|
591
|
+
dispatch({ type: 'OPEN_MODAL', modal: { ...modal, selected: newIdx } });
|
|
592
|
+
}
|
|
593
|
+
else if (key.name === 'enter') {
|
|
594
|
+
const newRole = roles[modal.selected];
|
|
595
|
+
if (newRole !== modal.currentRole) {
|
|
596
|
+
// Check if demoting an admin
|
|
597
|
+
if (modal.currentRole === 'admin' && newRole !== 'admin') {
|
|
598
|
+
const adminCount = state.members.filter(m => m.role === 'admin').length;
|
|
599
|
+
// Last admin protection — cannot demote the only admin
|
|
600
|
+
if (adminCount <= 1) {
|
|
601
|
+
showInlineError('cannot demote the last admin \u00b7 promote another member first', 2000);
|
|
602
|
+
dispatch({ type: 'CLOSE_MODAL' });
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if (adminCount > 1) {
|
|
606
|
+
// Initiate consensus for demotion
|
|
607
|
+
dispatch({ type: 'CLOSE_MODAL' });
|
|
608
|
+
dispatch({ type: 'OPEN_MODAL', modal: {
|
|
609
|
+
kind: 'initiate-consensus',
|
|
610
|
+
action: 'demote_admin',
|
|
611
|
+
description: 'demoting ' + modal.targetEmail,
|
|
612
|
+
targetUserId: modal.targetUserId,
|
|
613
|
+
} });
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
// Direct role change
|
|
618
|
+
const projectId = state.activeProjectId;
|
|
619
|
+
if (projectId) {
|
|
620
|
+
try {
|
|
621
|
+
await api.changeRole(projectId, modal.targetUserId, newRole);
|
|
622
|
+
const members = await api.listMembers(projectId);
|
|
623
|
+
dispatch({ type: 'SET_MEMBERS', members });
|
|
624
|
+
}
|
|
625
|
+
catch { }
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
dispatch({ type: 'CLOSE_MODAL' });
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
async function confirmModal(modal) {
|
|
633
|
+
const projectId = state.activeProjectId;
|
|
634
|
+
if (!projectId)
|
|
635
|
+
return;
|
|
636
|
+
switch (modal.kind) {
|
|
637
|
+
case 'confirm-forget':
|
|
638
|
+
try {
|
|
639
|
+
await api.deleteMemory(projectId, modal.memoryId);
|
|
640
|
+
dispatch({ type: 'REMOVE_MEMORY', id: modal.memoryId });
|
|
641
|
+
}
|
|
642
|
+
catch { }
|
|
643
|
+
break;
|
|
644
|
+
case 'confirm-forget-all':
|
|
645
|
+
case 'confirm-clear': {
|
|
646
|
+
const project = (0, state_1.getActiveProject)(state);
|
|
647
|
+
if (project && modal.input === project.slug) {
|
|
648
|
+
try {
|
|
649
|
+
await api.clearMemories(projectId);
|
|
650
|
+
dispatch({ type: 'SET_MEMORIES', memories: [], total: 0 });
|
|
651
|
+
}
|
|
652
|
+
catch { }
|
|
653
|
+
}
|
|
654
|
+
break;
|
|
655
|
+
}
|
|
656
|
+
case 'confirm-delete': {
|
|
657
|
+
const project = (0, state_1.getActiveProject)(state);
|
|
658
|
+
if (project && modal.input === project.slug) {
|
|
659
|
+
try {
|
|
660
|
+
await api.deleteProject(projectId);
|
|
661
|
+
const me = await api.me();
|
|
662
|
+
dispatch({ type: 'SET_USER_DATA', me });
|
|
663
|
+
dispatch({ type: 'SWITCH_TAB', tab: 'knowledge' });
|
|
664
|
+
if (state.activeProjectId) {
|
|
665
|
+
connectFeed(state.activeProjectId);
|
|
666
|
+
await loadTabData();
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
dispatch({ type: 'SET_MEMORIES', memories: [], total: 0 });
|
|
670
|
+
dispatch({ type: 'SET_MEMBERS', members: [] });
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
catch { }
|
|
674
|
+
}
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
case 'confirm-remove-member':
|
|
678
|
+
try {
|
|
679
|
+
await api.removeMember(projectId, modal.userId);
|
|
680
|
+
const members = await api.listMembers(projectId);
|
|
681
|
+
dispatch({ type: 'SET_MEMBERS', members });
|
|
682
|
+
}
|
|
683
|
+
catch { }
|
|
684
|
+
break;
|
|
685
|
+
case 'confirm-leave':
|
|
686
|
+
try {
|
|
687
|
+
// If admin who initiated a pending approval is leaving, cancel it
|
|
688
|
+
const leavingUserId = state.user.id;
|
|
689
|
+
for (const approval of state.pendingApprovals) {
|
|
690
|
+
// Check if this user initiated the approval (email match)
|
|
691
|
+
const leavingEmail = state.user.email;
|
|
692
|
+
if (approval.initiator_email === leavingEmail) {
|
|
693
|
+
try {
|
|
694
|
+
await api.cancelApproval(projectId, approval.id);
|
|
695
|
+
}
|
|
696
|
+
catch { }
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
await api.removeMember(projectId, leavingUserId);
|
|
700
|
+
const me = await api.me();
|
|
701
|
+
dispatch({ type: 'SET_USER_DATA', me });
|
|
702
|
+
dispatch({ type: 'SWITCH_TAB', tab: 'knowledge' });
|
|
703
|
+
if (state.activeProjectId) {
|
|
704
|
+
connectFeed(state.activeProjectId);
|
|
705
|
+
await loadTabData();
|
|
706
|
+
}
|
|
707
|
+
else {
|
|
708
|
+
dispatch({ type: 'SET_MEMORIES', memories: [], total: 0 });
|
|
709
|
+
dispatch({ type: 'SET_MEMBERS', members: [] });
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
catch { }
|
|
713
|
+
break;
|
|
714
|
+
case 'confirm-logout':
|
|
715
|
+
try {
|
|
716
|
+
const tokensFile = (0, path_1.join)((0, os_1.homedir)(), ".cohvu", "tokens.json");
|
|
717
|
+
if ((0, fs_1.existsSync)(tokensFile)) {
|
|
718
|
+
const { unlinkSync } = require("fs");
|
|
719
|
+
unlinkSync(tokensFile);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
catch { }
|
|
723
|
+
cleanExit();
|
|
724
|
+
break;
|
|
725
|
+
case 'rename': {
|
|
726
|
+
if (!modal.input)
|
|
727
|
+
break;
|
|
728
|
+
const slug = modal.input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
729
|
+
try {
|
|
730
|
+
await api.renameProject(projectId, modal.input, slug);
|
|
731
|
+
const me = await api.me();
|
|
732
|
+
dispatch({ type: 'SET_USER_DATA', me });
|
|
733
|
+
}
|
|
734
|
+
catch { }
|
|
735
|
+
break;
|
|
736
|
+
}
|
|
737
|
+
case 'create-project': {
|
|
738
|
+
if (!modal.input)
|
|
739
|
+
break;
|
|
740
|
+
const slug = modal.input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
741
|
+
try {
|
|
742
|
+
const project = await api.createProject(modal.input, slug);
|
|
743
|
+
await api.switchProject(project.id);
|
|
744
|
+
const me = await api.me();
|
|
745
|
+
dispatch({ type: 'SET_USER_DATA', me });
|
|
746
|
+
await loadTabData();
|
|
747
|
+
}
|
|
748
|
+
catch { }
|
|
749
|
+
break;
|
|
750
|
+
}
|
|
751
|
+
case 'initiate-consensus': {
|
|
752
|
+
try {
|
|
753
|
+
await api.initiateApproval(projectId, modal.action, modal.targetUserId);
|
|
754
|
+
const approvals = await api.listApprovals(projectId);
|
|
755
|
+
dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
|
|
756
|
+
}
|
|
757
|
+
catch { }
|
|
758
|
+
break;
|
|
759
|
+
}
|
|
760
|
+
case 'approve-action': {
|
|
761
|
+
try {
|
|
762
|
+
await api.approveAction(projectId, modal.approvalId);
|
|
763
|
+
const approvals = await api.listApprovals(projectId);
|
|
764
|
+
dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
|
|
765
|
+
}
|
|
766
|
+
catch { }
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
case 'confirm-regen-link': {
|
|
770
|
+
try {
|
|
771
|
+
await api.regenerateInviteLink(projectId, modal.role);
|
|
772
|
+
const links = await api.listInviteLinks(projectId);
|
|
773
|
+
dispatch({ type: 'SET_INVITE_LINKS', links });
|
|
774
|
+
}
|
|
775
|
+
catch { }
|
|
776
|
+
break;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
// ------ Data loading ------
|
|
781
|
+
async function loadTabData() {
|
|
782
|
+
const projectId = state.activeProjectId;
|
|
783
|
+
if (!projectId)
|
|
784
|
+
return;
|
|
785
|
+
try {
|
|
786
|
+
switch (state.tab) {
|
|
787
|
+
case 'knowledge': {
|
|
788
|
+
dispatch({ type: 'SET_LOADING', loading: true });
|
|
789
|
+
const result = await api.listMemories(projectId, { limit: PAGE_SIZE, offset: 0 });
|
|
790
|
+
dispatch({ type: 'SET_MEMORIES', memories: result.memories, total: result.total });
|
|
791
|
+
break;
|
|
792
|
+
}
|
|
793
|
+
case 'team': {
|
|
794
|
+
const [members, links] = await Promise.all([
|
|
795
|
+
api.listMembers(projectId),
|
|
796
|
+
api.listInviteLinks(projectId).catch(() => []),
|
|
797
|
+
]);
|
|
798
|
+
dispatch({ type: 'SET_MEMBERS', members });
|
|
799
|
+
dispatch({ type: 'SET_INVITE_LINKS', links });
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
802
|
+
case 'billing': {
|
|
803
|
+
const billing = await api.getBilling(projectId);
|
|
804
|
+
dispatch({ type: 'SET_BILLING', billing });
|
|
805
|
+
break;
|
|
806
|
+
}
|
|
807
|
+
case 'project': {
|
|
808
|
+
const approvals = await api.listApprovals(projectId).catch(() => []);
|
|
809
|
+
dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
|
|
810
|
+
break;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
catch { }
|
|
815
|
+
}
|
|
816
|
+
async function loadMoreMemories() {
|
|
817
|
+
const projectId = state.activeProjectId;
|
|
818
|
+
if (!projectId || !state.memoryHasMore)
|
|
819
|
+
return;
|
|
820
|
+
dispatch({ type: 'SET_LOADING', loading: true });
|
|
821
|
+
try {
|
|
822
|
+
const result = await api.listMemories(projectId, { limit: PAGE_SIZE, offset: state.memories.length });
|
|
823
|
+
dispatch({ type: 'SET_MEMORIES', memories: result.memories, total: result.total, append: true });
|
|
824
|
+
}
|
|
825
|
+
catch { }
|
|
826
|
+
}
|
|
827
|
+
async function executeSearch() {
|
|
828
|
+
const projectId = state.activeProjectId;
|
|
829
|
+
if (!projectId || state.searchQuery.length === 0)
|
|
830
|
+
return;
|
|
831
|
+
dispatch({ type: 'SET_SEARCHING', searching: true });
|
|
832
|
+
try {
|
|
833
|
+
const result = await api.searchMemories(projectId, state.searchQuery);
|
|
834
|
+
dispatch({ type: 'SET_SEARCH_RESULTS', results: result.memories });
|
|
835
|
+
}
|
|
836
|
+
catch { }
|
|
837
|
+
dispatch({ type: 'SET_SEARCHING', searching: false });
|
|
838
|
+
}
|
|
839
|
+
// ------ SSE feed ------
|
|
840
|
+
function connectFeed(projectId) {
|
|
841
|
+
if (feedDisconnect)
|
|
842
|
+
feedDisconnect();
|
|
843
|
+
const conn = api.connectFeed(projectId, (eventType, data) => {
|
|
844
|
+
if (eventType === 'memory') {
|
|
845
|
+
const event = data;
|
|
846
|
+
if (event.operation === 'create') {
|
|
847
|
+
dispatch({ type: 'ADD_MEMORY', memory: { id: event.id, body: event.body, updated_at: event.updated_at } });
|
|
848
|
+
dispatch({ type: 'SET_LIVE_DOT', memoryId: event.id });
|
|
849
|
+
if (liveDotTimer)
|
|
850
|
+
clearTimeout(liveDotTimer);
|
|
851
|
+
liveDotTimer = setTimeout(() => {
|
|
852
|
+
dispatch({ type: 'CLEAR_LIVE_DOT' });
|
|
853
|
+
}, 10000);
|
|
854
|
+
}
|
|
855
|
+
else if (event.operation === 'update') {
|
|
856
|
+
// Update the memory in place
|
|
857
|
+
const updated = { id: event.id, body: event.body, updated_at: event.updated_at };
|
|
858
|
+
state = {
|
|
859
|
+
...state,
|
|
860
|
+
memories: state.memories.map(m => m.id === event.id ? updated : m),
|
|
861
|
+
searchResults: state.searchResults?.map(m => m.id === event.id ? updated : m) ?? null,
|
|
862
|
+
};
|
|
863
|
+
dispatch({ type: 'SET_LIVE_DOT', memoryId: event.id });
|
|
864
|
+
if (liveDotTimer)
|
|
865
|
+
clearTimeout(liveDotTimer);
|
|
866
|
+
liveDotTimer = setTimeout(() => {
|
|
867
|
+
dispatch({ type: 'CLEAR_LIVE_DOT' });
|
|
868
|
+
}, 10000);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
else if (eventType === 'role_change') {
|
|
872
|
+
// Role changed for the current user — refresh user data
|
|
873
|
+
api.me().then(me => {
|
|
874
|
+
dispatch({ type: 'SET_USER_DATA', me });
|
|
875
|
+
}).catch(() => { });
|
|
876
|
+
}
|
|
877
|
+
else if (eventType === 'approval') {
|
|
878
|
+
// Approval state changed — refresh approvals
|
|
879
|
+
api.listApprovals(projectId).then(approvals => {
|
|
880
|
+
dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
|
|
881
|
+
}).catch(() => { });
|
|
882
|
+
}
|
|
883
|
+
else if (eventType === 'notification') {
|
|
884
|
+
// New notification — refresh notifications
|
|
885
|
+
api.listNotifications().then(notifications => {
|
|
886
|
+
dispatch({ type: 'SET_NOTIFICATIONS', notifications });
|
|
887
|
+
api.markNotificationsSeen().catch(() => { });
|
|
888
|
+
}).catch(() => { });
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
feedDisconnect = conn.disconnect;
|
|
892
|
+
dispatch({ type: 'SET_SSE_CONNECTED', connected: true });
|
|
893
|
+
}
|
|
894
|
+
// ------ File watchers for plugin detection ------
|
|
895
|
+
function setupFileWatchers() {
|
|
896
|
+
const claudeConfig = (0, path_1.join)((0, os_1.homedir)(), '.claude.json');
|
|
897
|
+
const cursorConfig = (0, path_1.join)((0, os_1.homedir)(), '.cursor', 'mcp.json');
|
|
898
|
+
for (const [path, platform] of [[claudeConfig, 'claude-code'], [cursorConfig, 'cursor']]) {
|
|
899
|
+
if ((0, fs_1.existsSync)(path)) {
|
|
900
|
+
try {
|
|
901
|
+
const w = (0, fs_1.watch)(path, () => {
|
|
902
|
+
if (checkMcpConfigured(path)) {
|
|
903
|
+
dispatch({ type: 'PLATFORM_CONFIGURED', platform });
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
watchers.push(w);
|
|
907
|
+
}
|
|
908
|
+
catch { }
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
// ------ Helpers ------
|
|
913
|
+
function openBrowser(url) {
|
|
914
|
+
const cmd = process.platform === "darwin" ? "open"
|
|
915
|
+
: process.platform === "win32" ? "start" : "xdg-open";
|
|
916
|
+
(0, child_process_1.execFile)(cmd, [url], () => { });
|
|
917
|
+
}
|
|
918
|
+
function copyToClipboard(text) {
|
|
919
|
+
const cmd = process.platform === "darwin" ? "pbcopy"
|
|
920
|
+
: process.platform === "win32" ? "clip" : "xclip -selection clipboard";
|
|
921
|
+
const child = (0, child_process_1.exec)(cmd);
|
|
922
|
+
child.stdin?.write(text);
|
|
923
|
+
child.stdin?.end();
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
function generateBanners(state) {
|
|
927
|
+
const lines = [];
|
|
928
|
+
const project = (0, state_1.getActiveProject)(state);
|
|
929
|
+
// Priority: pending approvals > notifications > locked > past due > trial ending > plugin nudge > joined
|
|
930
|
+
// 1. Pending approvals (admin only)
|
|
931
|
+
if (state.userRole === 'admin') {
|
|
932
|
+
for (const approval of state.pendingApprovals) {
|
|
933
|
+
const approved = approval.approved_by.length;
|
|
934
|
+
const required = approval.required_count;
|
|
935
|
+
const action = approval.action === 'delete_project' ? 'delete ' + (project?.slug ?? 'project')
|
|
936
|
+
: approval.action === 'clear_memories' ? 'clear memories'
|
|
937
|
+
: approval.action === 'demote_admin' ? 'demote ' + (approval.initiator_email ?? 'admin')
|
|
938
|
+
: approval.description;
|
|
939
|
+
lines.push(' ' + ansi_1.C.red + '! ' + ansi_1.C.secondary + 'approval pending: ' + action + ansi_1.C.reset +
|
|
940
|
+
ansi_1.C.muted + ' \u00b7 ' + approved + ' of ' + required + ' admins approved \u00b7 press p to review' + ansi_1.C.reset);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
// 2. Notifications (unseen)
|
|
944
|
+
for (const notif of state.notifications) {
|
|
945
|
+
if (!notif.seen) {
|
|
946
|
+
if (notif.type === 'role_change' && notif.message.includes('admin')) {
|
|
947
|
+
lines.push(' ' + ansi_1.C.green + ' ' + ansi_1.C.secondary + notif.message + ansi_1.C.reset);
|
|
948
|
+
}
|
|
949
|
+
else if (notif.type === 'role_change') {
|
|
950
|
+
lines.push(' ' + ansi_1.C.yellow + '! ' + ansi_1.C.secondary + notif.message + ansi_1.C.reset);
|
|
951
|
+
}
|
|
952
|
+
else if (notif.type === 'approval_completed') {
|
|
953
|
+
lines.push(' ' + ansi_1.C.red + '! ' + ansi_1.C.secondary + notif.message + ansi_1.C.reset);
|
|
954
|
+
}
|
|
955
|
+
else if (notif.type === 'approval_expired' || notif.type === 'approval_canceled') {
|
|
956
|
+
lines.push(' ' + ansi_1.C.muted + ' ' + notif.message + ansi_1.C.reset);
|
|
957
|
+
}
|
|
958
|
+
else {
|
|
959
|
+
lines.push(' ' + ansi_1.C.muted + ' ' + notif.message + ansi_1.C.reset);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
// Billing banners (suppress plugin nudges when billing issues exist)
|
|
964
|
+
let hasBillingBanner = false;
|
|
965
|
+
if (project) {
|
|
966
|
+
const trialDays = project.trial_ends_at ? (0, ansi_1.daysUntil)(project.trial_ends_at) : null;
|
|
967
|
+
// 3. Locked (trial ended, no subscription)
|
|
968
|
+
if (trialDays !== null && trialDays <= 0 && project.billing_status !== 'active') {
|
|
969
|
+
lines.push(' ' + ansi_1.C.red + '! ' + ansi_1.C.secondary + 'trial ended \u00b7 your agents have stopped working' + ansi_1.C.reset +
|
|
970
|
+
ansi_1.C.muted + ' \u00b7 press b to subscribe' + ansi_1.C.reset);
|
|
971
|
+
hasBillingBanner = true;
|
|
972
|
+
}
|
|
973
|
+
// 4. Past due
|
|
974
|
+
else if (project.billing_status === 'past_due') {
|
|
975
|
+
lines.push(' ' + ansi_1.C.red + '! ' + ansi_1.C.secondary + 'payment failed' + ansi_1.C.reset +
|
|
976
|
+
ansi_1.C.muted + ' \u00b7 update your payment method \u00b7 press b' + ansi_1.C.reset);
|
|
977
|
+
hasBillingBanner = true;
|
|
978
|
+
}
|
|
979
|
+
// 5. Trial ending soon (<= 3 days)
|
|
980
|
+
else if (trialDays !== null && trialDays > 0 && trialDays <= 3) {
|
|
981
|
+
const dayWord = trialDays === 1 ? 'tomorrow' : `in ${trialDays} days`;
|
|
982
|
+
lines.push(' ' + ansi_1.C.yellow + '! ' + ansi_1.C.secondary + 'trial ends ' + dayWord + ansi_1.C.reset +
|
|
983
|
+
ansi_1.C.muted + ' \u00b7 press b to subscribe' + ansi_1.C.reset);
|
|
984
|
+
hasBillingBanner = true;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
// Plugin nudge banners (only when no billing issues)
|
|
988
|
+
if (!hasBillingBanner) {
|
|
989
|
+
// First-login: show auto-configured platforms
|
|
990
|
+
if (state.firstLogin) {
|
|
991
|
+
const autoConfigured = state.platforms
|
|
992
|
+
.filter(p => p.state === 'configured' && !['claude code', 'cursor'].includes(p.name))
|
|
993
|
+
.map(p => p.name);
|
|
994
|
+
if (autoConfigured.length > 0) {
|
|
995
|
+
lines.push(' ' + ansi_1.C.green + ' ' + ansi_1.C.secondary + autoConfigured.join(', ') + ansi_1.C.reset +
|
|
996
|
+
ansi_1.C.muted + ' ready' + ansi_1.C.reset);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
// Plugin needed banners
|
|
1000
|
+
for (const p of state.platforms) {
|
|
1001
|
+
if (p.state === 'needs-plugin') {
|
|
1002
|
+
if (p.name === 'claude code') {
|
|
1003
|
+
lines.push(' ' + ansi_1.C.yellow + '\u2192 ' + ansi_1.C.secondary + 'open Claude Code and run ' + ansi_1.C.reset +
|
|
1004
|
+
ansi_1.C.primary + '/plugin marketplace add cohvu/cohvu' + ansi_1.C.reset);
|
|
1005
|
+
lines.push(' ' + ansi_1.C.dim + ' then ' + ansi_1.C.reset +
|
|
1006
|
+
ansi_1.C.primary + '/plugin install cohvu@cohvu' + ansi_1.C.reset);
|
|
1007
|
+
}
|
|
1008
|
+
else if (p.name === 'cursor') {
|
|
1009
|
+
lines.push(' ' + ansi_1.C.yellow + '\u2192 ' + ansi_1.C.secondary + 'open Cursor and run ' + ansi_1.C.reset +
|
|
1010
|
+
ansi_1.C.primary + '/add-plugin github.com/cohvu/cohvu-cursor' + ansi_1.C.reset);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
// Joined banner
|
|
1015
|
+
if (state.joinedProjectName) {
|
|
1016
|
+
lines.push(' ' + ansi_1.C.green + ' ' + ansi_1.C.secondary + 'you joined ' + state.joinedProjectName + ansi_1.C.reset +
|
|
1017
|
+
ansi_1.C.muted + ' \u00b7 your agent is ready' + ansi_1.C.reset);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
return lines;
|
|
1021
|
+
}
|
|
1022
|
+
function checkMcpConfigured(configPath) {
|
|
1023
|
+
try {
|
|
1024
|
+
const content = JSON.parse((0, fs_1.readFileSync)(configPath, 'utf8'));
|
|
1025
|
+
return !!content?.mcpServers?.cohvu;
|
|
1026
|
+
}
|
|
1027
|
+
catch {
|
|
1028
|
+
return false;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
function loadState() {
|
|
1032
|
+
try {
|
|
1033
|
+
if ((0, fs_1.existsSync)(STATE_FILE)) {
|
|
1034
|
+
return JSON.parse((0, fs_1.readFileSync)(STATE_FILE, 'utf8'));
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
catch { }
|
|
1038
|
+
return { hasOpenedDashboard: false };
|
|
1039
|
+
}
|
|
1040
|
+
function saveState(data) {
|
|
1041
|
+
try {
|
|
1042
|
+
const dir = (0, path_1.join)((0, os_1.homedir)(), ".cohvu");
|
|
1043
|
+
if (!(0, fs_1.existsSync)(dir)) {
|
|
1044
|
+
const { mkdirSync } = require("fs");
|
|
1045
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1046
|
+
}
|
|
1047
|
+
(0, fs_1.writeFileSync)(STATE_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
1048
|
+
}
|
|
1049
|
+
catch { }
|
|
1050
|
+
}
|
|
1051
|
+
//# sourceMappingURL=dashboard.js.map
|